Compare commits

...

196 commits
4.0.0 ... main

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

* Skip python-magic tests on windows

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

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

---------

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

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

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

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

* Update CHANGELOG.rst

* Exclude unsupported combinations

---------

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

Added support for default parameters to LibRavatar

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

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

---------

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

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

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

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

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

---------

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

* extend rest_framework to INSTALLED_APPS

* add api path to urls

* add requirements.txt to api app

* add self describe to assign_width_or_height function

* add django-avatar api docs

* remove unused files

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

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

---------

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

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

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

* fix(urls): change re_path into path

* fix(urls): remove unused imports

* fix(urls): url names

* fix(tests): update reverse

* fix(tags): update reverse

---------

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

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

* Update python and django versions

* Update coverage version

* Remove Django 4.0

Django 4.0 is EOL

* Fix flake8 error

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

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

---------

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

* doc: update because of #222

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

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

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

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

* fix: resize method -> Image.LANCZOS

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

* fix(deps): remove packaging

* fix(deps): remove packaging

---------

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

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

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

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

---------

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

* feat: add documentation for libravatar

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

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

I also refactored the matrix by removing duplicated code

* fix(deps): add missing dnspython

* feat(deps): add requirements.txt

* fix(gh-actions): install deps

* chore(deps): add pyproject.toml 

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

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

* fix(deps): bump coverage to 7.1.0

* fix(pre-commit): update versions

* fix(pre-commit): config error

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

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

* style: update code for passing flake

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

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

---------

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-27 17:53:01 +02:00
Johannes Wilm
4c5e6a0f52 7.0.0 2022-08-16 13:57:59 +02:00
Johannes Wilm
a897f5e8e9 Update gitignore 2022-08-16 13:56:25 +02:00
Johannes Wilm
c089561f48 add test for nondefault image names, fixes #38 2022-08-16 13:40:25 +02:00
Johannes Wilm
39f8681e29 Use correct extension for thumbnails, fixes #54 2022-08-16 13:28:09 +02:00
Johannes Wilm
210b54ca09 Moved default avatar to static folder, resolves #166 2022-08-16 12:01:35 +02:00
Johannes Wilm
0e09d40bf1 Correct thumbnail transposing for Safari, resolves #207 2022-08-16 11:55:31 +02:00
Johannes Wilm
8475d37c2a support Django 4.1 2022-08-15 12:54:41 +02:00
Johannes Wilm
baf1cfb1f7 typo 2022-08-15 12:09:29 +02:00
Johannes Wilm
9e9679167b Update changelog 2022-08-15 12:08:46 +02:00
Johannes Wilm
6f827c5312 Merge branch 'miohtama-python_magic_validation' 2022-08-15 12:04:22 +02:00
Johannes Wilm
d0ed3e3132 additional magic test 2022-08-15 12:03:54 +02:00
Johannes Wilm
a89a3fad17 Merge branch 'python_magic_validation' of github.com:miohtama/django-avatar into miohtama-python_magic_validation 2022-08-15 12:00:59 +02:00
Johannes Wilm
827244ca6c Merge branch 'rafiqhilali-fix-cached-image-sizes' 2022-08-15 11:34:59 +02:00
Johannes Wilm
28302c5205 Add invalidate_cache test 2022-08-15 11:34:35 +02:00
Johannes Wilm
72c1cae345 Make get_cache_key work when there is no width/height 2022-08-15 10:44:04 +02:00
pre-commit-ci[bot]
942e08080b [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-08-15 08:32:03 +00:00
Johannes Wilm
cee893f5e3
Merge branch 'main' into fix-cached-image-sizes 2022-08-15 10:31:24 +02:00
Johannes Wilm
9c5e4f55f2 Merge branch 'iMaGiNiX-master' 2022-08-15 10:17:36 +02:00
Johannes Wilm
198946a8af Merge branch 'master' of github.com:iMaGiNiX/django-avatar into iMaGiNiX-master 2022-08-15 10:17:24 +02:00
Johannes Wilm
99a979b057
rectangular avatars (#214)
* Support for rectangular avatars

* fix tests

* Add rectangle size test

* Update documentation and changelog

* add test for exposing username (or not)

* add primary_avatar_tag tests

* make rebuild_avatars, remove_avatar_images work with rectangles + tests

* Python 2 => 3

* fix tests

Co-authored-by: Karl Moritz Hermann <karlmoritz.hermann@gmail.com>
2022-08-15 10:08:35 +02:00
Rafiq Hilali
8017d6fc4c
Delete avatars from file storage when avatar is deleted (#174)
* added custom delete method to Avatar model inorder to delete avatars from file storage

* simplified chained expression to pass linting

* linting

* stopped using reserved keyword dir

* changed remove_avatar_images so it deletes all generated avatars

* went back to using queryset delete

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

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

Co-authored-by: Johannes Wilm <mail@johanneswilm.org>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-14 13:03:45 +02:00
Johannes Wilm
1dd993358b AVATAR_CLEANUP_DELETED default True, relates to #181 2022-08-14 13:00:53 +02:00
Jannis Leidel
2ced4c06e7
Fix name. 2022-08-12 12:46:27 +02:00
Jannis Leidel
6d9bd34de0
Use released version of workflow. 2022-08-12 12:45:45 +02:00
Johannes Wilm
49b85b12f8 6.0.1 2022-08-12 07:23:26 +02:00
Johannes Wilm
b422c7bc0d exclude tests folder from distribution 2022-08-12 07:20:47 +02:00
Johannes Wilm
47bd6f36ac 6.0.0 2022-08-12 07:01:23 +02:00
Jannis Leidel
5eb615f4b5
Fix rST rendering issue. 2022-08-11 10:49:32 +02:00
Jannis Leidel
1f7b1ff204
Add Django for docs rendering. 2022-08-11 10:44:23 +02:00
Johannes Wilm
8e086472d4 Merge branch 'main' of github.com:jazzband/django-avatar 2022-08-11 08:49:50 +02:00
Johannes Wilm
58aa1261ad always convert JPEG/RGBA to JPEG/RGB 2022-08-11 08:36:24 +02:00
Johannes Wilm
6c05228531
Update CHANGELOG.rst 2022-08-10 19:50:34 +02:00
Johannes Wilm
96a4dc7e91
Update CHANGELOG.rst 2022-08-10 19:49:11 +02:00
Johannes Wilm
a6cef676f3 Add items to changelog 2022-08-10 19:47:20 +02:00
Johannes Wilm
33afb0e8dc
more changelog styling 2022-08-10 19:41:53 +02:00
Johannes Wilm
1bf0542a2f
style changelog 2022-08-10 19:40:51 +02:00
Johannes Wilm
0f7ddf2c26 Add changelog 2022-08-10 19:39:34 +02:00
Johannes Wilm
9c03396893 only require Pillow 8.4.0 as that is what is available for Python 3.6 2022-08-10 19:06:25 +02:00
Johannes Wilm
2fb3034505 make AVATAR_THUMB_MODES configurable, solves #153, #180 2022-08-10 19:02:07 +02:00
Johannes Wilm
ab7e9c687e make PNG default thumbnail format, based on discussion in #180 2022-08-10 18:46:09 +02:00
Johannes Wilm
dca3143ec2 Merge branch 'caumons-eureka_pub' 2022-08-10 17:54:08 +02:00
Johannes Wilm
5452d4ea34 Merge branch 'eureka_pub' of github.com:caumons/django-avatar into caumons-eureka_pub 2022-08-10 17:53:32 +02:00
Johannes Wilm
b986082ae5
Merge pull request #139 from reidransom/tmploverride
Allow provider to override template_name and context
2022-08-10 17:47:32 +02:00
pre-commit-ci[bot]
c8bcdb4a0c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-08-10 15:45:56 +00:00
Johannes Wilm
e8fa4747f9
Merge branch 'main' into tmploverride 2022-08-10 17:45:47 +02:00
Johannes Wilm
4b912886ec tests: AVATAR_EXPOSE_USERNAMES = True 2022-08-10 17:40:05 +02:00
Johannes Wilm
7d276ead7a Don't expose user names through alt tags, solves #189, solves #188, relates to #181 2022-08-10 17:29:30 +02:00
Johannes Wilm
3e12122a9c make default for AVATAR_EXPOSE_USERNAMES False. Relates to #181 2022-08-10 17:24:00 +02:00
John Vandenberg
c89f7043c6
Update test_default_delete_template (#213) 2022-08-10 10:17:08 +08:00
John Vandenberg
862e4f7a23
Merge pull request #156 from dudanogueira/master 2022-08-10 07:54:10 +08:00
John Vandenberg
bc05f97c93
Merge branch 'main' into master 2022-08-10 07:50:03 +08:00
Johannes Wilm
114a154668 compile translation files, replaces #192 2022-08-09 23:24:52 +02:00
Johannes Wilm
daa47442e5
Merge pull request #194 from mh-firouzjah/master
add translation support for persian/farsi
2022-08-09 23:21:31 +02:00
Johannes Wilm
a4708eca55
Merge pull request #208 from GoldenMan123/patch-1
get rid of unnecessary text
2022-08-09 23:16:23 +02:00
Johannes Wilm
72a6751960 lint 2022-08-09 22:07:35 +02:00
Johannes Wilm
43dc264e82 fix repo url 2022-08-09 22:05:49 +02:00
Johannes Wilm
e11a93e2ed Add release workflow 2022-08-09 22:01:59 +02:00
Johannes Wilm
af1c241e92
Update CONTRIBUTORS.txt 2022-08-09 21:55:52 +02:00
Johannes Wilm
51a4a64b18
README: add badges 2022-08-09 21:54:56 +02:00
Johannes Wilm
61a1643151 Update badges 2022-08-09 21:47:07 +02:00
Johannes Wilm
502d96f81f
url() => path() 2022-08-09 21:42:20 +02:00
Johannes Wilm
6ccc046386 fix error type 2022-08-09 21:25:00 +02:00
Johannes Wilm
10986d7be9 github actions add PYTHONPATH 2022-08-09 21:23:03 +02:00
Johannes Wilm
eba927b7e2 github actions: add DJANGO_SETTINGS_MODULE 2022-08-09 21:21:32 +02:00
Johannes Wilm
a977753b2c lint and add pre-commit configuration 2022-08-09 21:17:24 +02:00
Johannes Wilm
f40705b739 github actions: add django 4.1, switch to Codecov 2022-08-09 20:54:25 +02:00
Johannes Wilm
b86706fec5 update setup.py 2022-07-16 23:51:41 +02:00
Johannes Wilm
a54fafabff coveralls --service=github 2022-07-16 23:45:31 +02:00
Johannes Wilm
9ffc4b60e7 move github secret to coveralls 2022-07-16 23:41:47 +02:00
Johannes Wilm
16f69072fa yml fix 2022-07-16 23:40:13 +02:00
Johannes Wilm
b45d211e09 add secrets.GITHUB_TOKEN 2022-07-16 23:38:40 +02:00
Johannes Wilm
4839dccfd5 change CI names 2022-07-16 23:32:02 +02:00
Johannes Wilm
c27a4d1794 url => re_path 2022-07-16 23:30:56 +02:00
Johannes Wilm
dab8741bed remove more six 2022-07-16 23:27:15 +02:00
Johannes Wilm
6ba1280a7e remove six 2022-07-16 23:25:43 +02:00
Johannes Wilm
0a84015352 coverage==6.2 2022-07-16 23:20:16 +02:00
Johannes Wilm
3dd349b577 black 2022-07-16 23:18:09 +02:00
Johannes Wilm
b7c8485f1e github actions: split lint & test 2022-07-16 23:15:01 +02:00
Johannes Wilm
ff0a5526ea coverage 6.4.2 2022-07-16 23:06:26 +02:00
Johannes Wilm
1c08dd84b7 add black req 2022-07-16 23:05:34 +02:00
Johannes Wilm
8f94d125b3 switch to github actions 2022-07-16 23:02:49 +02:00
Johannes Wilm
ae950c9b50 black 2022-07-16 22:50:05 +02:00
Johannes Wilm
96ae04858f Merge branch 'master' of github.com:grantmcconnaughey/django-avatar 2022-07-16 22:32:45 +02:00
Johannes Wilm
a131861589 Merge branch 'django4.0' of github.com:johanneswilm/django-avatar 2022-07-16 22:27:50 +02:00
Johannes Wilm
03c95bc925 update imports 2022-07-16 22:26:58 +02:00
Grant McConnaughey
51cbdbd5c7
Merge pull request #209 from johanneswilm/django4.0 2022-05-11 13:10:38 -05:00
Johannes Wilm
a1aae28859 Merge branch 'master' of github.com:ntoll/django-avatar into django4.0 2022-05-11 11:52:38 +02:00
Johannes Wilm
288f3cacf2 Merge branch 'patch-1' of github.com:rsp2k/django-avatar into django4.0 2022-05-11 11:47:28 +02:00
Vladimir Zhukov
a0998c4bde
get rid of unnecessary text 2022-03-20 17:24:17 +03:00
rsp2k
61f135fa71
Django 4.0: ugettext -> gettext 2022-01-27 13:33:44 -07:00
rsp2k
12a6d65454
Update forms.py 2022-01-27 13:22:00 -07:00
rsp2k
be9187fb62
Update views.py 2022-01-27 13:20:33 -07:00
rsp2k
346530c14c
Django 4.0: url -> re_path
https://docs.djangoproject.com/en/4.0/ref/urls/#re-path
2022-01-27 13:16:55 -07:00
rsp2k
9fd68769c8
Django 4.0: Signal -> providing_args deprecated
https://docs.djangoproject.com/en/4.0/releases/3.1/#id2
2022-01-27 13:06:16 -07:00
rsp2k
d941939441
Django 4.0: ugettext_lazy -> gettext_lazy 2022-01-27 13:03:33 -07:00
rsp2k
ec6f8bbf2b
Django 4.0: force_text -> force_str
https://docs.djangoproject.com/en/4.0/ref/utils/#module-django.utils.encoding
2022-01-27 12:56:09 -07:00
rsp2k
623f529a0b
Django 4.0: ugettext_lazy -> gettext_lazy
ugettext_lazy` was deprecated in v2.2 and no longer used in django v3
https://docs.djangoproject.com/en/4.0/releases/4.0/#features-removed-in-4-0
2022-01-27 12:44:39 -07:00
Nicholas H.Tollervey
68340e8ac6
Update to work with Django 4.0. Based upon PR #201. 2022-01-27 18:41:45 +00:00
Johannes Wilm
348fade5a8 Merge branch 'original-file-default' of github.com:johanneswilm/django-avatar 2021-06-25 14:53:51 +02:00
Johannes Wilm
85218b0403 Make Django 3.2 compatible 2021-06-25 14:50:44 +02:00
Mahdi Firouzjaah
2917cc5690
Add files via upload 2020-12-16 20:13:11 +03:30
Mahdi Firouzjaah
22cb67fd23
Create django.po 2020-12-16 20:12:50 +03:30
Johannes Wilm
53b7fa2265
use original file as default
If thumbnail creation fails but the original files could be read, save the original file as the thumbnail.
2020-03-02 18:47:00 +01:00
Grant McConnaughey
3f4a8d284e Bump version to 5.0.0 2020-01-04 16:00:34 -06:00
Grant McConnaughey
ce56f6a104
Merge pull request #177 from rafiqhilali/avatar-deletion-storage-bug
Avatar storage deletion bug
2020-01-04 11:41:27 -06:00
Grant McConnaughey
5c4f587e6e
Merge pull request #184 from grantmcconnaughey/new_python_django_versions
Test against Django 2/3, Python 3.7/3.8
2020-01-04 11:10:38 -06:00
Grant McConnaughey
6ce67a8709 Add required deps for Django 2.2+ 2020-01-04 11:01:36 -06:00
Grant McConnaughey
991c8657de Fix settings for Django 2.1+ 2020-01-04 10:52:15 -06:00
Grant McConnaughey
a10771fc47 Test against Django 2/3, Python 3.7/3.8 2020-01-04 10:37:01 -06:00
Grant McConnaughey
25edc4172b
Merge pull request #183 from bastbnl/bastbnl-patch-182
Fixes the Django 3.0 `six` issue
2019-12-14 10:32:46 -06:00
bastb
805920e521 Yet another attempt 2019-12-03 15:43:31 +01:00
bastb
4ab2b379e7 Autocorrect 2019-12-03 15:41:20 +01:00
bastb
6a2c361502 Additional fixes for Django 3 2019-12-03 15:37:01 +01:00
bastb
2793ff0830 Fixes the Django 3.0 six issue 2019-12-03 08:16:14 +01:00
Rafiq Hilali
15879af5ce fixed storage deletion bug 2019-02-11 17:12:11 +00:00
Rafiq Hilali
0e66ae5de7 simplified chained comparison 2019-02-08 10:03:23 +00:00
Rafiq Hilali
10ee97b902 cached the sizes of avatars that have been added to the cache, and then used these cached sizes to clear the cache in invalidate_cache 2019-02-07 16:27:18 +00:00
Grant McConnaughey
182e7aa641
Merge pull request #169 from spookyUnknownUser/patch-1
Fix docs showing old django url pattern
2018-10-04 13:29:52 -05:00
spookyUnknownUser
319d554873
Fix docs showing old django url pattern 2018-10-04 18:36:44 +02:00
Grant McConnaughey
0d761fdd88
Merge pull request #168 from ricardogsilva/167-avoid-generation-of-migrations-file-when-adding-i18n-to-django-project
use the lazy version of gettext for translating models
2018-07-02 09:30:30 -05:00
Ricardo Garcia Silva
a26a133d9d
use the lazy version of gettext for translating models 2018-07-02 12:08:00 +01:00
Grant McConnaughey
f1266ed1eb Bump version 2017-12-20 09:22:33 -06:00
Grant McConnaughey
8b83871634 Update changelog 2017-12-20 09:21:55 -06:00
Grant McConnaughey
6d4a1b27d1 Use MIDDLEWARE setting 2017-12-17 11:22:56 -06:00
Grant McConnaughey
b6c9ff1d03 RST is not Markdown 2017-12-17 10:31:15 -06:00
Grant McConnaughey
7fb00f634b Exclude bare except rule 2017-12-17 10:29:26 -06:00
Grant McConnaughey
2b767e0b85 Update Changelog 2017-12-17 10:29:15 -06:00
Grant McConnaughey
16d721c210 Merge branch 'Jimdo-exif' 2017-12-17 10:24:16 -06:00
Grant McConnaughey
2de2a60e85 Fix conflicts 2017-12-17 10:24:02 -06:00
Grant McConnaughey
f940db8dc9
Merge pull request #162 from sedlar/signal_avatar_deleted
Add avatar_deleted signal
2017-12-17 10:22:31 -06:00
Grant McConnaughey
ba552fbcd9 Fix reverse in tests 2017-12-17 10:21:43 -06:00
Grant McConnaughey
10e0853152 Support Django 1.9 2017-12-17 10:17:27 -06:00
Grant McConnaughey
1511e67d66 Update testing matrix 2017-12-17 10:17:22 -06:00
Grant McConnaughey
d9c162cda9
Merge pull request #163 from cavanierc/Django2
Support for Django 2
2017-12-17 10:09:09 -06:00
Clement Cavanier
69901569a3 Support for Django 2 2017-12-06 15:06:45 +01:00
Martin Sedlář
6010209a1b Fix compatibility with python2 2017-11-10 10:16:05 +01:00
Martin Sedlář
fb195a570f Add avatar_deleted signal 2017-11-09 23:20:59 +01:00
Duda Nogueira
f31a9ae092 Confirm delete template: only show selection avatar paragraph when avatars are available. 2017-09-06 15:46:07 -03:00
Grant McConnaughey
4eaf6f8f1e Document AVATAR_CLEANUP_DELETED 2017-08-27 08:53:30 -05:00
Grant McConnaughey
dcc472a1e2 Document AVATAR_MAX_AVATARS_PER_USER 2017-08-27 08:51:18 -05:00
Fabian Stehle
dcabd11e43 Fix invalid translations
'msgid' and 'msgstr' entries do not both end with '\n' which is
mandatory according to specification from the 'msgfmt' manual:

```
The msgid and msgstr strings are studied and compared. It is considered
abnormal if one string starts or ends with a newline while the other
does not.
```

Besides all placeholders from the 'msgid' must be used in the 'mgstr'.
2017-08-27 08:47:26 -05:00
Duda Nogueira
4abf3277a2 Doc: Add url method to url to avoid error on django newer versions 2017-08-27 08:46:53 -05:00
Grant McConnaughey
f8716937b0 Prevented creation of the new migrations with non-default DEFAULT_FILE_STORAGE 2017-08-27 08:45:45 -05:00
Duda Nogueira
e43a4bd725 Doc: Add url method to url to avoid error on django newer versions 2017-08-09 09:00:39 -03:00
Fabian Stehle
631eac80bb Test transposing thumbnails based on exif data 2017-07-11 21:57:08 +02:00
Arne Holzenburg
341732a38c Transpose based on EXIF orientation
Transpose the original image before resizing based on EXIF orientation
information to display image in the correct orientation. If someone
uploads an image from an iPhone (for example) the image is currently
often rendered in the wrong orientation.
2017-07-11 14:42:55 +02:00
reidransom
55be600311 lint fixes 2016-10-29 15:26:36 -04:00
reidransom
0e87dc2f82 Allow provider to override template_name and context
Also an example provider which renders the user's initals against
a randomly colored background.
2016-10-29 15:09:57 -04:00
Igor Mella
d7db61b275 Prevent thumbnails clones
To prevent the creation of multiple thumbnails clones "fileName.hash.ext"
2015-01-11 17:49:10 -03:00
Enric Caumons
5ad41df92c prevents crash when deleting user with attached avatars (fixes #52) 2013-09-10 01:19:29 +02:00
Mikko Ohtamaa
217baaa317 Added image content type white list with python-magic 2013-07-19 01:36:33 +03:00
Enric Caumons
3fd8461d9e Replaced django.conf.urls.defaults by django.conf.urls to suppress warning 2013-07-03 20:07:17 +02:00
74 changed files with 2404 additions and 775 deletions

38
.github/workflows/release.yml vendored Normal file
View 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
View 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
View file

@ -1,4 +1,5 @@
*.pyc
__pycache__
build/
src/
pip-log.txt
@ -12,3 +13,7 @@ docs/_build
htmlcov/
*.sqlite3
test_proj/media
.python-version
/test-media/
.envrc
.direnv/

29
.pre-commit-config.yaml Normal file
View 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
View file

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

View file

@ -1,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

View file

@ -1,5 +1,69 @@
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)
* **Backwards incompatible:** Added ``AVATAR_PROVIDERS`` setting. Avatar providers are classes that return an avatar URL for a given user.

3
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,3 @@
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](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).

View file

@ -1,4 +1,3 @@
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

View file

@ -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
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -3,6 +3,6 @@ include LICENSE.txt
include CONTRIBUTORS.txt
include avatar/media/avatar/img/default.jpg
recursive-include docs *
recursive-include tests *
recursive-include avatar/templates *.html *.txt
recursive-include avatar/locale/*/LC_MESSAGES *.mo *.po
recursive-exclude tests *

View file

@ -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

View file

@ -2,29 +2,35 @@
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
:target: https://badge.fury.io/py/django-avatar
:alt: PyPI badge
.. image:: https://readthedocs.org/projects/django-avatar/badge/?version=latest
:target: http://django-avatar.readthedocs.org/en/latest/?badge=latest
:target: https://django-avatar.readthedocs.org/en/latest/?badge=latest
:alt: Documentation Status
.. 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
ability to default to Gravatar if no avatar is found for a certain user.
Django-avatar automatically generates thumbnails and stores them to your default
file storage backend for retrieval later.
For more information see the documentation at http://django-avatar.readthedocs.org/
For more information see the documentation at https://django-avatar.readthedocs.org/

View file

@ -1 +1 @@
__version__ = '4.0.0'
__version__ = "9.0.0"

View file

@ -1,7 +1,6 @@
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.utils.translation import gettext_lazy as _
from avatar.models import Avatar
from avatar.signals import avatar_updated
@ -9,25 +8,28 @@ from avatar.utils import get_user_model
class AvatarAdmin(admin.ModelAdmin):
list_display = ('get_avatar', 'user', 'primary', "date_uploaded")
list_filter = ('primary',)
search_fields = ('user__%s' % getattr(get_user_model(), 'USERNAME_FIELD', 'username'),)
list_display = ("get_avatar", "user", "primary", "date_uploaded")
list_filter = ("primary",)
autocomplete_fields = ("user",)
search_fields = (
"user__%s" % getattr(get_user_model(), "USERNAME_FIELD", "username"),
)
list_per_page = 50
def get_avatar(self, avatar_in):
context = dict({
'user': avatar_in.user,
'url': avatar_in.avatar.url,
'alt': six.text_type(avatar_in.user),
'size': 80,
})
return render_to_string('avatar/avatar_tag.html', context)
context = {
"user": avatar_in.user,
"url": avatar_in.avatar.url,
"alt": str(avatar_in.user),
"size": 80,
}
return render_to_string("avatar/avatar_tag.html", context)
get_avatar.short_description = _('Avatar')
get_avatar.short_description = _("Avatar")
get_avatar.allow_tags = True
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)

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

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

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

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

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

View file

View file

@ -0,0 +1 @@
djangorestframework

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

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

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

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

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

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

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

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

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

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

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

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

6
avatar/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class Config(AppConfig):
name = "avatar"
default_auto_field = "django.db.models.AutoField"

View file

@ -1,43 +1,48 @@
from appconf import AppConf
from django.conf import settings
from PIL import Image
from appconf import AppConf
class AvatarConf(AppConf):
DEFAULT_SIZE = 80
RESIZE_METHOD = Image.ANTIALIAS
STORAGE_DIR = 'avatars'
PATH_HANDLER = 'avatar.models.avatar_path_handler'
GRAVATAR_BASE_URL = 'https://www.gravatar.com/avatar/'
GRAVATAR_FIELD = 'email'
RESIZE_METHOD = Image.Resampling.LANCZOS
STORAGE_DIR = "avatars"
PATH_HANDLER = "avatar.models.avatar_path_handler"
GRAVATAR_BASE_URL = "https://www.gravatar.com/avatar/"
GRAVATAR_FIELD = "email"
GRAVATAR_DEFAULT = None
AVATAR_GRAVATAR_FORCEDEFAULT = False
DEFAULT_URL = 'avatar/img/default.jpg'
DEFAULT_URL = "avatar/img/default.jpg"
MAX_AVATARS_PER_USER = 42
MAX_SIZE = 1024 * 1024
THUMB_FORMAT = 'JPEG'
THUMB_FORMAT = "PNG"
THUMB_QUALITY = 85
THUMB_MODES = ("RGB", "RGBA")
HASH_FILENAMES = False
HASH_USERDIRNAMES = False
EXPOSE_USERNAMES = True
EXPOSE_USERNAMES = False
ALLOWED_FILE_EXTS = None
ALLOWED_MIMETYPES = None
CACHE_TIMEOUT = 60 * 60
STORAGE = settings.DEFAULT_FILE_STORAGE
CLEANUP_DELETED = False
if hasattr(settings, "DEFAULT_FILE_STORAGE"):
STORAGE = settings.DEFAULT_FILE_STORAGE # deprecated settings
STORAGE_ALIAS = "default"
CLEANUP_DELETED = True
AUTO_GENERATE_SIZES = (DEFAULT_SIZE,)
FACEBOOK_GET_ID = None
CACHE_ENABLED = True
RANDOMIZE_HASHES = False
ADD_TEMPLATE = ''
CHANGE_TEMPLATE = ''
DELETE_TEMPLATE = ''
ADD_TEMPLATE = ""
CHANGE_TEMPLATE = ""
DELETE_TEMPLATE = ""
PROVIDERS = (
'avatar.providers.PrimaryAvatarProvider',
'avatar.providers.GravatarAvatarProvider',
'avatar.providers.DefaultAvatarProvider',
"avatar.providers.PrimaryAvatarProvider",
"avatar.providers.LibRAvatarProvider",
"avatar.providers.GravatarAvatarProvider",
"avatar.providers.DefaultAvatarProvider",
)
def configure_auto_generate_avatar_sizes(self, value):
return value or getattr(settings, 'AVATAR_AUTO_GENERATE_SIZES',
(self.DEFAULT_SIZE,))
return value or getattr(
settings, "AVATAR_AUTO_GENERATE_SIZES", (self.DEFAULT_SIZE,)
)

View file

@ -2,85 +2,131 @@ import os
from django import forms
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.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from PIL import Image, ImageOps
from avatar.conf import settings
from avatar.models import Avatar
def avatar_img(avatar, size):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
return mark_safe('<img src="%s" alt="%s" width="%s" height="%s" />' %
(avatar.avatar_url(size), six.text_type(avatar),
size, size))
def avatar_img(avatar, width, height):
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return mark_safe(
'<img src="%s" alt="%s" width="%s" height="%s" />'
% (avatar.avatar_url(width, height), str(avatar), width, height)
)
class UploadAvatarForm(forms.Form):
avatar = forms.ImageField(label=_("avatar"))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
super(UploadAvatarForm, self).__init__(*args, **kwargs)
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
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:
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 forms.ValidationError(error %
{'ext': ext,
'valid_exts_list': valid_exts})
error = _(
"%(ext)s is an invalid file extension. "
"Authorized extensions are : %(valid_exts_list)s"
)
raise forms.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 forms.ValidationError(error % {
'size': filesizeformat(data.size),
'max_valid_size': filesizeformat(settings.AVATAR_MAX_SIZE)
})
error = _(
"Your file is too big (%(size)s), "
"the maximum allowed size is %(max_valid_size)s"
)
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()
if (settings.AVATAR_MAX_AVATARS_PER_USER > 1 and
count >= settings.AVATAR_MAX_AVATARS_PER_USER):
error = _("You already have %(nb_avatars)d avatars, "
"and the maximum allowed is %(nb_max_avatars)d.")
raise forms.ValidationError(error % {
'nb_avatars': count,
'nb_max_avatars': settings.AVATAR_MAX_AVATARS_PER_USER,
})
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 forms.ValidationError(
error
% {
"nb_avatars": count,
"nb_max_avatars": settings.AVATAR_MAX_AVATARS_PER_USER,
}
)
return
class PrimaryAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.pop('user')
size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop('avatars')
super(PrimaryAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields['choice'] = forms.ChoiceField(label=_("Choices"),
choices=choices,
widget=widgets.RadioSelect)
kwargs.pop("user")
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars")
super().__init__(*args, **kwargs)
self.fields["choice"] = forms.ChoiceField(
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.RadioSelect,
)
class DeleteAvatarForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.pop('user')
size = kwargs.pop('size', settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop('avatars')
super(DeleteAvatarForm, self).__init__(*args, **kwargs)
choices = [(avatar.id, avatar_img(avatar, size)) for avatar in avatars]
self.fields['choices'] = forms.MultipleChoiceField(label=_("Choices"),
choices=choices,
widget=widgets.CheckboxSelectMultiple)
kwargs.pop("user")
width = kwargs.pop("width", settings.AVATAR_DEFAULT_SIZE)
height = kwargs.pop("height", settings.AVATAR_DEFAULT_SIZE)
avatars = kwargs.pop("avatars")
super().__init__(*args, **kwargs)
self.fields["choices"] = forms.MultipleChoiceField(
label=_("Choices"),
choices=[(c.id, avatar_img(c, width, height)) for c in avatars],
widget=widgets.CheckboxSelectMultiple,
)

Binary file not shown.

View 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 "آواتارهای مدنظر با موفقیت حذف شدند."

View file

@ -117,8 +117,9 @@ msgid ""
"\n"
"http://%(current_site)s%(avatar_url)s\n"
msgstr ""
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> a mis à jour son avatar <a "
"href=\"%(avatar_url)s\">%(avatar)s</a>."
"%(avatar_creator)s a mis à jour son avatar %(avatar)s\n"
"\n"
"http://%(current_site)s%(avatar_url)s\n"
#: templates/notification/avatar_friend_updated/notice.html:2
#, python-format

View file

@ -121,8 +121,9 @@ msgid ""
"\n"
"http://%(current_site)s%(avatar_url)s\n"
msgstr ""
"<a href=\"%(user_url)s\">%(avatar_creator)s</a> обновил свои аватары <a href="
"\"%(avatar_url)s\">%(avatar)s</a>."
"%(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

View file

@ -1,17 +1,26 @@
from django.core.management.base import BaseCommand
from avatar.conf import settings
from avatar.models import Avatar
from avatar.models import Avatar, remove_avatar_images
class Command(BaseCommand):
help = ("Regenerates avatar thumbnails for the sizes specified in "
"settings.AVATAR_AUTO_GENERATE_SIZES.")
help = (
"Regenerates avatar thumbnails for the sizes specified in "
"settings.AVATAR_AUTO_GENERATE_SIZES."
)
def handle(self, *args, **options):
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:
if options['verbosity'] != 0:
print("Rebuilding Avatar id=%s at size %s." % (avatar.id, size))
avatar.create_thumbnail(size)
if options["verbosity"] != 0:
self.stdout.write(
"Rebuilding Avatar id=%s at size %s." % (avatar.id, 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])

View file

@ -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.utils.timezone
from django.conf import settings
from django.db import migrations, models
import avatar.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Avatar',
name="Avatar",
fields=[
('id', models.AutoField(verbose_name='ID', 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)),
(
"id",
models.AutoField(
verbose_name="ID",
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
),
),
],
),
]

View file

@ -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
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
import avatar.models
class Migration(migrations.Migration):
dependencies = [
('avatar', '0001_initial'),
("avatar", "0001_initial"),
]
operations = [
migrations.AlterModelOptions(
name='avatar',
options={'verbose_name': 'avatar', 'verbose_name_plural': 'avatars'},
name="avatar",
options={"verbose_name": "avatar", "verbose_name_plural": "avatars"},
),
migrations.AlterField(
model_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'),
model_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",
),
),
migrations.AlterField(
model_name='avatar',
name='date_uploaded',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='uploaded at'),
model_name="avatar",
name="date_uploaded",
field=models.DateTimeField(
default=django.utils.timezone.now, verbose_name="uploaded at"
),
),
migrations.AlterField(
model_name='avatar',
name='primary',
field=models.BooleanField(default=False, verbose_name='primary'),
model_name="avatar",
name="primary",
field=models.BooleanField(default=False, verbose_name="primary"),
),
migrations.AlterField(
model_name='avatar',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'),
model_name="avatar",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View 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(),
),
]

View file

@ -1,32 +1,35 @@
import binascii
import datetime
import os
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.base import ContentFile
from django.core.files.storage import get_storage_class
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 import models
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.utils import get_username, force_bytes, invalidate_cache
from avatar.utils import get_username, invalidate_cache
try:
from django.utils.timezone import now
try: # Django 4.2+
from django.core.files.storage import storages
avatar_storage = storages[settings.AVATAR_STORAGE_ALIAS]
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, size=None, ext=None):
def avatar_path_handler(
instance=None, filename=None, width=None, height=None, ext=None
):
tmppath = [settings.AVATAR_STORAGE_DIR]
if settings.AVATAR_HASH_USERDIRNAMES:
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:
tmppath.append(get_username(instance.user))
else:
tmppath.append(force_text(instance.user.pk))
tmppath.append(force_str(instance.user.pk))
if not filename:
# Filename already stored in database
filename = instance.avatar.name
if ext and settings.AVATAR_HASH_FILENAMES:
# 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
if ext:
(root, oldext) = os.path.splitext(filename)
filename = root + "." + ext
filename = root + "." + ext.lower()
else:
# File doesn't exist yet
(root, oldext) = os.path.splitext(filename)
if settings.AVATAR_HASH_FILENAMES:
(root, ext) = os.path.splitext(filename)
if settings.AVATAR_RANDOMIZE_HASHES:
filename = binascii.hexlify(os.urandom(16)).decode('ascii')
root = binascii.hexlify(os.urandom(16)).decode("ascii")
else:
filename = hashlib.md5(force_bytes(filename)).hexdigest()
filename = filename + ext
if size:
tmppath.extend(['resized', str(size)])
root = hashlib.md5(force_bytes(root)).hexdigest()
if ext:
filename = root + "." + ext.lower()
else:
filename = root + oldext.lower()
if width or height:
tmppath.extend(["resized", str(width), str(height)])
tmppath.append(os.path.basename(filename))
return os.path.join(*tmppath)
@ -66,40 +68,49 @@ avatar_file_path = import_string(settings.AVATAR_PATH_HANDLER)
def find_extension(format):
format = format.lower()
if format == 'jpeg':
format = 'jpg'
if format == "jpeg":
format = "jpg"
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):
user = models.ForeignKey(
getattr(settings, 'AUTH_USER_MODEL', 'auth.User'),
getattr(settings, "AUTH_USER_MODEL", "auth.User"),
verbose_name=_("user"),
on_delete=models.CASCADE,
)
primary = models.BooleanField(
verbose_name=_("primary"),
default=False,
)
avatar = models.ImageField(
verbose_name=_("avatar"),
max_length=1024,
upload_to=avatar_file_path,
storage=avatar_storage,
blank=True,
)
avatar = AvatarField(verbose_name=_("avatar"))
date_uploaded = models.DateTimeField(
verbose_name=_("uploaded at"),
default=now,
)
class Meta:
app_label = 'avatar'
verbose_name = _('avatar')
verbose_name_plural = _('avatars')
app_label = "avatar"
verbose_name = _("avatar")
verbose_name_plural = _("avatars")
def __unicode__(self):
return _(six.u('Avatar for %s')) % self.user
def __str__(self):
return _("Avatar for %s") % self.user
def save(self, *args, **kwargs):
avatars = Avatar.objects.filter(user=self.user)
@ -111,69 +122,104 @@ class Avatar(models.Model):
avatars.update(primary=False)
else:
avatars.delete()
super(Avatar, self).save(*args, **kwargs)
super().save(*args, **kwargs)
def thumbnail_exists(self, size):
return self.avatar.storage.exists(self.avatar_name(size))
def thumbnail_exists(self, width, height=None):
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_cache(self.user, size)
invalidate_cache(self.user, width, height)
try:
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)
orig = self.avatar.storage.open(self.avatar.name, "rb")
except IOError:
return # What should we do here? Render a "sorry, didn't work" img?
def avatar_url(self, size):
return self.avatar.storage.url(self.avatar_name(size))
with closing(orig):
try:
image = Image.open(orig)
except IOError:
thumb_file = File(orig)
else:
image = self.transpose_image(image)
quality = quality or settings.AVATAR_THUMB_QUALITY
w, h = image.size
if w != width or h != height:
ratioReal = 1.0 * w / h
ratioWant = 1.0 * width / height
if ratioReal > ratioWant:
diff = int((w - (h * ratioWant)) / 2)
image = image.crop((diff, 0, w - diff, h))
elif ratioReal < ratioWant:
diff = int((h - (w / ratioWant)) / 2)
image = image.crop((0, diff, w, h - diff))
if settings.AVATAR_THUMB_FORMAT == "JPEG" and image.mode == "RGBA":
image = image.convert("RGB")
elif image.mode not in (settings.AVATAR_THUMB_MODES):
image = image.convert(settings.AVATAR_THUMB_MODES[0])
image = image.resize((width, height), settings.AVATAR_RESIZE_METHOD)
thumb = BytesIO()
image.save(thumb, settings.AVATAR_THUMB_FORMAT, quality=quality)
thumb_file = ContentFile(thumb.getvalue())
else:
thumb_file = File(orig)
thumb_name = self.avatar_name(width, height)
thumb = self.avatar.storage.save(thumb_name, thumb_file)
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):
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)
return avatar_file_path(
instance=self,
size=size,
ext=ext
)
return avatar_file_path(instance=self, width=width, height=height, ext=ext)
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):
invalidate_avatar_cache(sender, instance)
if created:
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):
for size in settings.AVATAR_AUTO_GENERATE_SIZES:
if instance.thumbnail_exists(size):
instance.avatar.storage.delete(instance.avatar_name(size))
instance.avatar.storage.delete(instance.avatar.name)
def remove_avatar_images(instance=None, delete_main_avatar=True, **kwargs):
base_filepath = 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")
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)

View file

@ -1,27 +1,19 @@
import hashlib
import re
from urllib.parse import urlencode, urljoin
try:
from urllib.parse import urljoin, urlencode
except ImportError:
from urlparse import urljoin
from urllib import urlencode
import dns.resolver
from django.utils.module_loading import import_string
from avatar.conf import settings
from avatar.utils import (
force_bytes,
get_default_avatar_url,
get_primary_avatar,
)
from django.utils.module_loading import import_string
from avatar.utils import force_bytes, get_default_avatar_url, get_primary_avatar
# If the FacebookAvatarProvider is used, a mechanism needs to be defined on
# how to obtain the user's Facebook UID. This is done via
# ``AVATAR_FACEBOOK_GET_ID``.
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):
get_facebook_id = settings.AVATAR_FACEBOOK_GET_ID
else:
@ -34,7 +26,7 @@ class DefaultAvatarProvider(object):
"""
@classmethod
def get_avatar_url(self, user, size):
def get_avatar_url(cls, user, width, height=None):
return get_default_avatar_url()
@ -44,10 +36,12 @@ class PrimaryAvatarProvider(object):
"""
@classmethod
def get_avatar_url(self, user, size):
avatar = get_primary_avatar(user, size)
def get_avatar_url(cls, user, width, height=None):
if not height:
height = width
avatar = get_primary_avatar(user, width, height)
if avatar:
return avatar.avatar_url(size)
return avatar.avatar_url(width, height)
class GravatarAvatarProvider(object):
@ -56,29 +50,88 @@ class GravatarAvatarProvider(object):
"""
@classmethod
def get_avatar_url(self, user, size):
params = {'s': str(size)}
def get_avatar_url(cls, user, width, _height=None):
params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params['d'] = 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(getattr(user,
settings.AVATAR_GRAVATAR_FIELD))).hexdigest(), urlencode(params))
params["f"] = "y"
path = "%s/?%s" % (
hashlib.md5(
force_bytes(getattr(user, settings.AVATAR_GRAVATAR_FIELD))
).hexdigest(),
urlencode(params),
)
return urljoin(settings.AVATAR_GRAVATAR_BASE_URL, path)
class LibRAvatarProvider:
"""
Returns the url of an avatar by the LibRavatar service.
"""
@classmethod
def get_avatar_url(cls, user, width, _height=None):
email = getattr(user, settings.AVATAR_GRAVATAR_FIELD).encode("utf-8")
try:
_, domain = email.split(b"@")
answers = dns.resolver.query("_avatars._tcp." + domain, "SRV")
hostname = re.sub(r"\.$", "", str(answers[0].target))
# query returns "example.com." and while http requests are fine with this,
# https most certainly do not consider "example.com." and "example.com" to be the same.
port = str(answers[0].port)
if port == "443":
baseurl = "https://" + hostname + "/avatar/"
else:
baseurl = "http://" + hostname + ":" + port + "/avatar/"
except Exception:
baseurl = "https://seccdn.libravatar.org/avatar/"
params = {"s": str(width)}
if settings.AVATAR_GRAVATAR_DEFAULT:
params["d"] = settings.AVATAR_GRAVATAR_DEFAULT
if settings.AVATAR_GRAVATAR_FORCEDEFAULT:
params["f"] = "y"
path = "%s/?%s" % (
hashlib.md5(force_bytes(email.strip().lower())).hexdigest(),
urlencode(params),
)
return urljoin(baseurl, path)
class FacebookAvatarProvider(object):
"""
Returns the url of a Facebook profile image.
"""
@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)
if fb_id:
url = 'https://graph.facebook.com/{fb_id}/picture?type=square&width={size}&height={size}'
return url.format(
fb_id=fb_id,
size=size
)
url = "https://graph.facebook.com/{fb_id}/picture?type=square&width={width}&height={height}"
return url.format(fb_id=fb_id, width=width, height=height)
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)

View file

@ -1,4 +1,4 @@
import django.dispatch
avatar_updated = django.dispatch.Signal(providing_args=["user", "avatar"])
avatar_updated = django.dispatch.Signal()
avatar_deleted = django.dispatch.Signal()

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

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

View file

@ -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 %}/>

View file

@ -5,4 +5,4 @@
<body>
{% block content %}{% endblock %}
</body>
</html>
</html>

View file

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

View file

@ -2,12 +2,12 @@
{% load i18n %}
{% block content %}
<p>{% trans "Please select the avatars that you would like to delete." %}</p>
{% if not avatars %}
{% url 'avatar_change' as avatar_change_url %}
{% url 'avatar:change' as avatar_change_url %}
<p>{% blocktrans %}You have no avatars to delete. Please <a href="{{ avatar_change_url }}">upload one</a> now.{% endblocktrans %}</p>
{% else %}
<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>
{{ delete_avatar_form.as_ul }}
</ul>

View 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>

View file

@ -1,2 +1,2 @@
{% 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 %}

View file

@ -1,56 +1,69 @@
from django import template
from django.core.urlresolvers import reverse
from django.template.loader import render_to_string
from django.utils import six
from django.utils.translation import ugettext as _
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from avatar.conf import settings
from avatar.models import Avatar
from avatar.utils import (
cache_result,
get_default_avatar_url,
get_user_model,
get_user,
)
from avatar.utils import cache_result, get_default_avatar_url, get_user, get_user_model
register = template.Library()
@cache_result()
@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:
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:
return avatar_url
return get_default_avatar_url()
@cache_result()
@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()):
try:
user = get_user(user)
alt = six.text_type(user)
url = avatar_url(user, size)
if settings.AVATAR_EXPOSE_USERNAMES:
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, width, height)
except get_user_model().DoesNotExist:
url = get_default_avatar_url()
alt = _("Default Avatar")
else:
alt = six.text_type(user)
url = avatar_url(user, size)
kwargs.update({'alt': alt})
if settings.AVATAR_EXPOSE_USERNAMES:
alt = str(user)
else:
alt = _("User Avatar")
url = avatar_url(user, width, height)
kwargs.update({"alt": alt})
context = {
'user': user,
'url': url,
'size': size,
'kwargs': kwargs,
"user": user,
"alt": alt,
"width": width,
"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
@ -62,48 +75,44 @@ def has_avatar(user):
@cache_result()
@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
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,
we will avoid many db calls.
"""
alt = six.text_type(user)
url = reverse('avatar_render_primary', kwargs={'user': user, 'size': size})
return ("""<img src="%s" alt="%s" width="%s" height="%s" />""" %
(url, alt, size, size))
kwargs = {"width": width}
if settings.AVATAR_EXPOSE_USERNAMES:
alt = str(user)
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()
@register.simple_tag
def render_avatar(avatar, size=settings.AVATAR_DEFAULT_SIZE):
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
def render_avatar(avatar, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
height = width
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return """<img src="%s" alt="%s" width="%s" height="%s" />""" % (
avatar.avatar_url(size), six.text_type(avatar), size, size)
@register.tag
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()
avatar.avatar_url(width, height),
str(avatar),
width,
height,
)

View file

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

View file

@ -1,44 +1,52 @@
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.core.cache import cache
from django.template.defaultfilters import slugify
from django.utils.encoding import force_bytes
from avatar.conf import settings
cached_funcs = set()
def get_username(user):
""" Return username of a User instance """
if hasattr(user, 'get_username'):
"""Return username of a User instance"""
if hasattr(user, "get_username"):
return user.get_username()
else:
return user.username
def get_user(username):
""" Return user from a username/ish identifier """
return get_user_model().objects.get_by_natural_key(username)
def get_user(userdescriptor):
"""Return user from a username/ID/ish identifier"""
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.
"""
if isinstance(user_or_username, get_user_model()):
user_or_username = get_username(user_or_username)
key = six.u('%s_%s_%s') % (prefix, user_or_username, size)
return six.u('%s_%s') % (slugify(key)[:100],
hashlib.md5(force_bytes(key)).hexdigest())
key = f"{prefix}_{user_or_username}"
if width:
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):
@ -48,61 +56,76 @@ def cache_set(key, value):
def cache_result(default_size=settings.AVATAR_DEFAULT_SIZE):
"""
Decorator to cache the result of functions that take a ``user`` and a
``size`` value.
Decorator to cache the result of functions that take a ``user``, a
``width`` and a ``height`` value.
"""
if not settings.AVATAR_CACHE_ENABLED:
def decorator(func):
return func
return decorator
def decorator(func):
def cached_func(user, size=None, **kwargs):
def cached_func(user, width=None, height=None, **kwargs):
prefix = func.__name__
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)
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)
# 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 cached_func
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)
if size is not None:
sizes.add(size)
sizes_key = get_cache_key(user, "cached_sizes")
sizes = cache.get(sizes_key, set())
if width is not None:
sizes.add((width, height or width))
for prefix in cached_funcs:
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():
base_url = getattr(settings, 'STATIC_URL', None)
base_url = getattr(settings, "STATIC_URL", None)
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://
if settings.AVATAR_DEFAULT_URL.startswith(('http://', 'https://')):
if settings.AVATAR_DEFAULT_URL.startswith(("http://", "https://")):
return settings.AVATAR_DEFAULT_URL
# 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:
base_url = base_url[:-1]
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()
if not isinstance(user, User):
try:
@ -118,6 +141,6 @@ def get_primary_avatar(user, size=settings.AVATAR_DEFAULT_SIZE):
except IndexError:
avatar = None
if avatar:
if not avatar.thumbnail_exists(size):
avatar.create_thumbnail(size)
if not avatar.thumbnail_exists(width, height):
avatar.create_thumbnail(width, height)
return avatar

View file

@ -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.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.forms import PrimaryAvatarForm, DeleteAvatarForm, UploadAvatarForm
from avatar.forms import DeleteAvatarForm, PrimaryAvatarForm, UploadAvatarForm
from avatar.models import Avatar
from avatar.signals import avatar_updated
from avatar.utils import (get_primary_avatar, get_default_avatar_url,
invalidate_cache)
from avatar.signals import avatar_deleted, avatar_updated
from avatar.utils import get_default_avatar_url, get_primary_avatar, invalidate_cache
def _get_next(request):
@ -28,8 +25,9 @@ def _get_next(request):
3. If Django can determine the previous page from the HTTP headers,
the view will redirect to that previous page.
"""
next = request.POST.get('next', request.GET.get('next',
request.META.get('HTTP_REFERER', None)))
next = request.POST.get(
"next", request.GET.get("next", request.META.get("HTTP_REFERER", None))
)
if not next:
next = request.path
return next
@ -40,7 +38,7 @@ def _get_avatars(user):
avatars = user.avatar_set.all()
# Current avatar
primary_avatar = avatars.order_by('-primary')[:1]
primary_avatar = avatars.order_by("-primary")[:1]
if primary_avatar:
avatar = primary_avatar[0]
else:
@ -51,59 +49,70 @@ def _get_avatars(user):
else:
# Slice the default set now that we used
# 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)
@login_required
def add(request, extra_context=None, next_override=None,
upload_form=UploadAvatarForm, *args, **kwargs):
def add(
request,
extra_context=None,
next_override=None,
upload_form=UploadAvatarForm,
*args,
**kwargs,
):
if extra_context is None:
extra_context = {}
avatar, avatars = _get_avatars(request.user)
upload_avatar_form = upload_form(request.POST or None,
request.FILES or None,
user=request.user)
if request.method == "POST" and 'avatar' in request.FILES:
upload_avatar_form = upload_form(
request.POST or None, request.FILES or None, user=request.user
)
if request.method == "POST" and "avatar" in request.FILES:
if upload_avatar_form.is_valid():
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.save()
messages.success(request, _("Successfully uploaded a new avatar."))
avatar_updated.send(sender=Avatar, user=request.user, avatar=avatar)
return redirect(next_override or _get_next(request))
context = {
'avatar': avatar,
'avatars': avatars,
'upload_avatar_form': upload_avatar_form,
'next': next_override or _get_next(request),
"avatar": avatar,
"avatars": avatars,
"upload_avatar_form": upload_avatar_form,
"next": next_override or _get_next(request),
}
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)
@login_required
def change(request, extra_context=None, next_override=None,
upload_form=UploadAvatarForm, primary_form=PrimaryAvatarForm,
*args, **kwargs):
def change(
request,
extra_context=None,
next_override=None,
upload_form=UploadAvatarForm,
primary_form=PrimaryAvatarForm,
*args,
**kwargs,
):
if extra_context is None:
extra_context = {}
avatar, avatars = _get_avatars(request.user)
if avatar:
kwargs = {'initial': {'choice': avatar.id}}
kwargs = {"initial": {"choice": avatar.id}}
else:
kwargs = {}
upload_avatar_form = upload_form(user=request.user, **kwargs)
primary_avatar_form = primary_form(request.POST or None,
user=request.user,
avatars=avatars, **kwargs)
primary_avatar_form = primary_form(
request.POST or None, user=request.user, avatars=avatars, **kwargs
)
if request.method == "POST":
updated = False
if 'choice' in request.POST and primary_avatar_form.is_valid():
avatar = Avatar.objects.get(
id=primary_avatar_form.cleaned_data['choice'])
if "choice" in request.POST and primary_avatar_form.is_valid():
avatar = Avatar.objects.get(id=primary_avatar_form.cleaned_data["choice"])
avatar.primary = True
avatar.save()
updated = True
@ -114,14 +123,14 @@ def change(request, extra_context=None, next_override=None,
return redirect(next_override or _get_next(request))
context = {
'avatar': avatar,
'avatars': avatars,
'upload_avatar_form': upload_avatar_form,
'primary_avatar_form': primary_avatar_form,
'next': next_override or _get_next(request)
"avatar": avatar,
"avatars": avatars,
"upload_avatar_form": upload_avatar_form,
"primary_avatar_form": primary_avatar_form,
"next": next_override or _get_next(request),
}
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)
@ -130,47 +139,61 @@ def delete(request, extra_context=None, next_override=None, *args, **kwargs):
if extra_context is None:
extra_context = {}
avatar, avatars = _get_avatars(request.user)
delete_avatar_form = DeleteAvatarForm(request.POST or None,
user=request.user,
avatars=avatars)
if request.method == 'POST':
delete_avatar_form = DeleteAvatarForm(
request.POST or None, user=request.user, avatars=avatars
)
if request.method == "POST":
if delete_avatar_form.is_valid():
ids = delete_avatar_form.cleaned_data['choices']
if six.text_type(avatar.id) in ids and avatars.count() > len(ids):
ids = delete_avatar_form.cleaned_data["choices"]
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
for a in avatars:
if six.text_type(a.id) not in ids:
if str(a.id) not in ids:
a.primary = True
a.save()
avatar_updated.send(sender=Avatar, user=request.user,
avatar=avatar)
avatar_updated.send(
sender=Avatar, user=request.user, avatar=avatar
)
break
Avatar.objects.filter(id__in=ids).delete()
messages.success(request,
_("Successfully deleted the requested avatars."))
messages.success(request, _("Successfully deleted the requested avatars."))
return redirect(next_override or _get_next(request))
context = {
'avatar': avatar,
'avatars': avatars,
'delete_avatar_form': delete_avatar_form,
'next': next_override or _get_next(request),
"avatar": avatar,
"avatars": avatars,
"delete_avatar_form": delete_avatar_form,
"next": next_override or _get_next(request),
}
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)
def render_primary(request, user=None, size=settings.AVATAR_DEFAULT_SIZE):
size = int(size)
avatar = get_primary_avatar(user, size=size)
def render_primary(request, user=None, width=settings.AVATAR_DEFAULT_SIZE, height=None):
if height is None:
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:
# FIXME: later, add an option to render the resized avatar dynamically
# instead of redirecting to an already created static file. This could
# be useful in certain situations, particulary if there is a CDN and
# we want to minimize the storage usage on our static server, letting
# the CDN store those files instead
url = avatar.avatar_url(size)
url = avatar.avatar_url(width, height)
else:
url = get_default_avatar_url()

View file

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

191
docs/avatar.rst Normal file
View file

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

View file

@ -11,204 +11,208 @@
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
import os
import sys
# 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
# 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 -----------------------------------------------------
# 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
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
templates_path = ["_templates"]
# The suffix of source filenames.
source_suffix = '.txt'
source_suffix = ".rst"
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# General information about the project.
project = u'django-avatar'
copyright = u'2013, django-avatar developers'
project = "django-avatar"
copyright = "2013, django-avatar developers"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '2.0'
version = "2.0"
# The full version, including alpha/beta/rc tags.
release = '2.0'
release = "2.0"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# today = ''
# 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
# 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.
#default_role = None
# default_role = None
# 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
# unit titles (such as .. function::).
#add_module_names = True
# add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# show_authors = False
# 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.
#modindex_common_prefix = []
# modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# keep_warnings = False
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# 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
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# html_theme_options = {}
# 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
# "<project> v<release> documentation".
#html_title = None
# html_title = None
# 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
# 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
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# html_favicon = None
# 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,
# 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,
# 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
# typographically correct entities.
#html_use_smartypants = True
# html_use_smartypants = True
# 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
# template names.
#html_additional_pages = {}
# html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# html_domain_indices = True
# 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.
#html_split_index = False
# html_split_index = False
# 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.
#html_show_sphinx = True
# html_show_sphinx = 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
# contain a <link> tag referring to it. The value of this option must be the
# 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").
#html_file_suffix = None
# html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-avatardoc'
htmlhelp_basename = "django-avatardoc"
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
# The paper size ('letterpaper' or 'a4paper').
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
# 'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
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 title page.
#latex_logo = None
# latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# latex_show_pagerefs = False
# 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.
#latex_appendices = []
# latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
@ -216,12 +220,17 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
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.
#man_show_urls = False
# man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
@ -230,19 +239,25 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-avatar', u'django-avatar Documentation',
u'django-avatar developers', 'django-avatar', 'One line description of project.',
'Miscellaneous'),
(
"index",
"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.
#texinfo_appendices = []
# texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# texinfo_domain_indices = True
# 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.
#texinfo_no_detailmenu = False
# texinfo_no_detailmenu = False

View file

@ -1,3 +1,4 @@
django-avatar
=============
@ -7,7 +8,7 @@ or Facebook) if no avatar is found for a certain user. Django-avatar
automatically generates thumbnails and stores them to your default file
storage backend for retrieval later.
.. _Gravatar: http://gravatar.com
.. _Gravatar: https://gravatar.com
Installation
------------
@ -48,13 +49,13 @@ that are required. A minimal integration can work like this:
urlpatterns = [
# ...
(r'^avatar/', include('avatar.urls')),
path('avatar/', include('avatar.urls')),
]
4. Somewhere in your template navigation scheme, link to the change avatar
page::
<a href="{% url 'avatar_change' %}">Change your avatar</a>
<a href="{% url 'avatar:change' %}">Change your avatar</a>
5. Wherever you want to display an avatar for a user, first load the avatar
template tags::
@ -69,6 +70,10 @@ that are required. A minimal integration can work like this:
{% 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::
{% 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
An iterable of integers representing the sizes of avatars to generate on
upload. This can save rendering time later on if you pre-generate the
resized versions. Defaults to ``(80,)``
An iterable of integers and/or sequences in the format ``(width, height)``
representing the sizes of avatars to generate on upload. This can save
rendering time later on if you pre-generate the resized versions. Defaults
to ``(80,)``.
.. 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
``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
@ -156,6 +162,11 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_MAX_SIZE
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
@ -170,12 +181,13 @@ appear on the site. Listed below are those settings:
(
'avatar.providers.PrimaryAvatarProvider',
'avatar.providers.LibRAvatarProvider',
'avatar.providers.GravatarAvatarProvider',
'avatar.providers.DefaultAvatarProvider',
)
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
@ -200,7 +212,7 @@ appear on the site. Listed below are those settings:
.. py:data:: AVATAR_RESIZE_METHOD
The method to use when resizing images, based on the options available in
PIL. Defaults to ``Image.ANTIALIAS``.
Pillow. Defaults to ``Image.Resampling.LANCZOS``.
.. 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
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
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
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
-------------------
@ -231,4 +278,37 @@ the avatars for the pixel sizes specified in the
:py:data:`AVATAR_AUTO_GENERATE_SIZES` setting.
.. _pip: http://www.pip-installer.org/
.. _pip: https://www.pip-installer.org/
-----------------------------------------------
API
---
To use API there are relatively few things that are required.
after `Installation <#installation>`_ .
1. in your ``INSTALLED_APPS`` of your settings file : ::
INSTALLED_APPS = (
# ...
'avatar',
'rest_framework'
)
2. Add the avatar api urls to the end of your root url config : ::
urlpatterns = [
# ...
path('api/', include('avatar.api.urls')),
]
-----------------------------------------------
.. toctree::
:maxdepth: 1
avatar

View file

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

40
pyproject.toml Normal file
View file

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

3
requirements.txt Normal file
View file

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

View file

@ -1,66 +1,33 @@
import codecs
import re
from os import path
from setuptools import setup, find_packages
from setuptools import find_packages, setup
def read(*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()
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
version_file, re.M)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
setup(
name='django-avatar',
version=find_version("avatar", "__init__.py"),
description="A Django app for handling user avatars",
long_description=read('README.rst'),
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Framework :: Django',
'Intended Audience :: Developers',
'Framework :: Django',
'Framework :: Django :: 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']),
packages=find_packages(exclude=["tests"]),
package_data={
'avatar': [
'templates/notification/*/*.*',
'templates/avatar/*.html',
'locale/*/LC_MESSAGES/*',
'media/avatar/img/default.jpg',
"avatar": [
"templates/notification/*/*.*",
"templates/avatar/*.html",
"locale/*/LC_MESSAGES/*",
"media/avatar/img/default.jpg",
],
},
install_requires=[
'Pillow>=2.0',
'django-appconf>=0.6',
],
zip_safe=False,
)

View file

@ -1,26 +1,26 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
if __name__ == "__main__":
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_proj.settings")
# Add the django-avatar directory to the Python path. That way the
# avatar module can be imported.
sys.path.append('..')
sys.path.append("..")
try:
from django.core.management import execute_from_command_line
except ImportError:
# The above import may fail for some other reason. Ensure that the
# issue is really that Django is missing to avoid masking other
# exceptions on Python 2.
try:
import django
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
except ImportError as exc:
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?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

@ -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/
# 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!
DEBUG = True
@ -31,54 +31,54 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'avatar',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"avatar",
"rest_framework",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = 'test_proj.urls'
ROOT_URLCONF = "test_proj.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'test_proj.wsgi.application'
WSGI_APPLICATION = "test_proj.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
@ -86,9 +86,9 @@ DATABASES = {
# Internationalization
# 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
@ -100,7 +100,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# 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_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"

View file

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

BIN
tests/data/django #3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
tests/data/test.tiff Normal file

Binary file not shown.

View file

@ -1,3 +1,3 @@
flake8
coverage==4.2
django-discover-runner
coverage~=7.1.0
django
python-magic

View file

@ -1,27 +1,27 @@
import os
import django
VERSION = django.VERSION
SETTINGS_DIR = os.path.dirname(__file__)
DATABASE_ENGINE = 'sqlite3'
DATABASE_ENGINE = "sqlite3"
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sites',
'avatar',
"django.contrib.admin",
"django.contrib.messages",
"django.contrib.sessions",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sites",
"avatar",
]
MIDDLEWARE_CLASSES = (
MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@ -29,34 +29,35 @@ MIDDLEWARE_CLASSES = (
"django.contrib.messages.middleware.MessageMiddleware",
)
if VERSION[0] == 1 and VERSION[1] < 8:
TEMPLATE_DIRS = (
os.path.join(SETTINGS_DIR, 'templates'),
)
else:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'DIRS': [
os.path.join(SETTINGS_DIR, 'templates')
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"DIRS": [os.path.join(SETTINGS_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
]
}
]
},
}
]
ROOT_URLCONF = 'tests.urls'
ROOT_URLCONF = "tests.urls"
SITE_ID = 1
SECRET_KEY = 'something-something'
SECRET_KEY = "something-something"
if django.VERSION[:2] < (1, 6):
TEST_RUNNER = 'discover_runner.DiscoverRunner'
ROOT_URLCONF = "tests.urls"
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_AVATARS_PER_USER = 20
AVATAR_AUTO_GENERATE_SIZES = [51, 62, (33, 22), 80]
MEDIA_ROOT = os.path.join(SETTINGS_DIR, "../test-media")

View file

@ -1,37 +1,97 @@
import math
import os.path
import sys
from pathlib import Path
from shutil import rmtree
from unittest import skipIf
from django.contrib.admin.sites import AdminSite
from django.core import management
from django.core.cache import cache
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from django.urls import reverse
from PIL import Image, ImageChops
from avatar.admin import AvatarAdmin
from avatar.conf import settings
from avatar.utils import get_primary_avatar, get_user_model
from avatar.models import Avatar
from avatar.signals import avatar_deleted
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):
f = open(os.path.join(o.testdatapath, filename), "rb")
response = o.client.post(reverse('avatar_add'), {
'avatar': f,
}, follow=True)
response = o.client.post(
reverse("avatar:add"),
{
"avatar": f,
},
follow=True,
)
f.close()
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):
@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):
self.testdatapath = os.path.join(os.path.dirname(__file__), "data")
self.user = get_user_model().objects.create_user('test', 'lennon@thebeatles.com', 'testpassword')
self.user = get_user_model().objects.create_user(
"test", "lennon@thebeatles.com", "testpassword"
)
self.user.save()
self.client.login(username='test', password='testpassword')
self.client.login(username="test", password="testpassword")
self.site = AdminSite()
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):
self.test_normal_image_upload()
self.test_normal_image_upload()
@ -47,46 +107,79 @@ class AvatarTests(TestCase):
def test_non_image_upload(self):
response = upload_helper(self, "nonimagefile")
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):
response = upload_helper(self, "test.png")
self.assertEqual(response.status_code, 200)
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)
self.assertIsNotNone(avatar)
self.assertEqual(avatar.user, self.user)
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):
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
response = upload_helper(self, "imagefilewithoutext")
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, {})
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):
# use with AVATAR_ALLOWED_FILE_EXTS = ('.jpg', '.png')
response = upload_helper(self, "imagefilewithwrongext.ogg")
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, {})
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
def test_image_too_big(self):
# use with AVATAR_MAX_SIZE = 1024 * 1024
response = upload_helper(self, "testbig.png")
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, {})
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
def test_default_url(self):
response = self.client.get(reverse('avatar_render_primary', kwargs={
'user': self.user.username,
'size': 80,
}))
loc = response['Location']
base_url = getattr(settings, 'STATIC_URL', None)
response = self.client.get(
reverse(
"avatar:render_primary",
kwargs={
"user": self.user.username,
"width": 80,
},
)
)
loc = response["Location"]
base_url = getattr(settings, "STATIC_URL", None)
if not base_url:
base_url = settings.MEDIA_URL
self.assertTrue(base_url in loc)
@ -97,7 +190,7 @@ class AvatarTests(TestCase):
self.assertEqual(a, None)
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()
count = Avatar.objects.filter(user=self.user, primary=True).count()
self.assertEqual(count, 1)
@ -106,21 +199,34 @@ class AvatarTests(TestCase):
self.test_normal_image_upload()
avatar = Avatar.objects.filter(user=self.user)
self.assertEqual(len(avatar), 1)
response = self.client.post(reverse('avatar_delete'), {
'choices': [avatar[0].id],
}, follow=True)
receiver = AssertSignal()
avatar_deleted.connect(receiver)
response = self.client.post(
reverse("avatar:delete"),
{
"choices": [avatar[0].id],
},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.redirect_chain), 1)
count = Avatar.objects.filter(user=self.user).count()
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):
self.test_there_can_be_only_one_primary_avatar()
primary = get_primary_avatar(self.user)
oid = primary.id
self.client.post(reverse('avatar_delete'), {
'choices': [oid],
})
self.client.post(
reverse("avatar:delete"),
{
"choices": [oid],
},
)
primaries = Avatar.objects.filter(user=self.user, primary=True)
self.assertEqual(len(primaries), 1)
self.assertNotEqual(oid, primaries[0].id)
@ -129,48 +235,94 @@ class AvatarTests(TestCase):
def test_change_avatar_get(self):
self.test_normal_image_upload()
response = self.client.get(reverse('avatar_change'))
response = self.client.get(reverse("avatar:change"))
self.assertEqual(response.status_code, 200)
self.assertIsNotNone(response.context['avatar'])
self.assertIsNotNone(response.context["avatar"])
def test_change_avatar_post_updates_primary_avatar(self):
self.test_there_can_be_only_one_primary_avatar()
old_primary = Avatar.objects.get(user=self.user, primary=True)
choice = Avatar.objects.filter(user=self.user, primary=False)[0]
response = self.client.post(reverse('avatar_change'), {
'choice': choice.pk,
})
response = self.client.post(
reverse("avatar:change"),
{
"choice": choice.pk,
},
)
self.assertEqual(response.status_code, 302)
new_primary = Avatar.objects.get(user=self.user, primary=True)
self.assertEqual(new_primary.pk, choice.pk)
# 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):
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()
count_before = Avatar.objects.filter(user=self.user).count()
response = upload_helper(self, "test.png")
count_after = Avatar.objects.filter(user=self.user).count()
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, {})
self.assertNotEqual(response.context["upload_avatar_form"].errors, {})
self.assertEqual(count_before, count_after)
@override_settings(AVATAR_THUMB_FORMAT='png')
def test_automatic_thumbnail_creation_RGBA(self):
upload_helper(self, "django.png")
avatar = get_primary_avatar(self.user)
image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb'))
self.assertEqual(image.mode, 'RGBA')
image = Image.open(
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):
upload_helper(self, "django_pony_cmyk.jpg")
avatar = get_primary_avatar(self.user)
image = Image.open(avatar.avatar.storage.open(avatar.avatar_name(settings.AVATAR_DEFAULT_SIZE), 'rb'))
self.assertEqual(image.mode, 'RGB')
image = Image.open(
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):
self.assertFalse(avatar_tags.has_avatar(self.user))
@ -189,6 +341,16 @@ class AvatarTests(TestCase):
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('width="80" height="80" alt="test" />', result)
@ -199,7 +361,7 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user)
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):
upload_helper(self, "test.png")
@ -208,53 +370,166 @@ class AvatarTests(TestCase):
result = avatar_tags.avatar(self.user, 100)
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):
upload_helper(self, "test.png")
avatar = get_primary_avatar(self.user)
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)
def test_default_add_template(self):
response = self.client.get('/avatar/add/')
self.assertContains(response, 'Upload New Image')
self.assertNotContains(response, 'ALTERNATE ADD TEMPLATE')
def test_primary_avatar_tag_works(self):
upload_helper(self, "test.png")
@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):
response = self.client.get('/avatar/add/')
self.assertNotContains(response, 'Upload New Image')
self.assertContains(response, 'ALTERNATE ADD TEMPLATE')
response = self.client.get("/avatar/add/")
self.assertNotContains(response, "Upload New Image")
self.assertContains(response, "ALTERNATE ADD TEMPLATE")
def test_default_change_template(self):
response = self.client.get('/avatar/change/')
self.assertContains(response, 'Upload New Image')
self.assertNotContains(response, 'ALTERNATE CHANGE TEMPLATE')
response = self.client.get("/avatar/change/")
self.assertContains(response, "Upload New Image")
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):
response = self.client.get('/avatar/change/')
self.assertNotContains(response, 'Upload New Image')
self.assertContains(response, 'ALTERNATE CHANGE TEMPLATE')
response = self.client.get("/avatar/change/")
self.assertNotContains(response, "Upload New Image")
self.assertContains(response, "ALTERNATE CHANGE TEMPLATE")
def test_default_delete_template(self):
response = self.client.get('/avatar/delete/')
self.assertContains(response, 'like to delete.')
self.assertNotContains(response, 'ALTERNATE DELETE TEMPLATE')
upload_helper(self, "test.png")
response = self.client.get("/avatar/delete/")
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):
response = self.client.get('/avatar/delete/')
self.assertNotContains(response, 'like to delete.')
self.assertContains(response, 'ALTERNATE DELETE TEMPLATE')
response = self.client.get("/avatar/delete/")
self.assertNotContains(response, "like to delete.")
self.assertContains(response, "ALTERNATE DELETE TEMPLATE")
# def testAvatarOrder
# def testReplaceAvatarWhenMaxIsOne
# def testHashFileName
# def testHashUserName
# def testChangePrimaryAvatar
# def testDeleteThumbnailAndRecreation
# def testAutomaticThumbnailCreation
def get_media_file_mtime(self, path):
full_path = os.path.join(self.testmediapath, f".{path}")
return os.path.getmtime(full_path)
def test_rebuild_avatars(self):
upload_helper(self, "test.png")
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)

View file

@ -1,6 +1,5 @@
from django.conf.urls import include, url
from django.urls import include, re_path
urlpatterns = [
url(r'^avatar/', include('avatar.urls')),
re_path(r"^avatar/", include("avatar.urls")),
]