├── .editorconfig ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── pictures ├── __init__.py ├── apps.py ├── checks.py ├── conf.py ├── contrib │ ├── __init__.py │ └── rest_framework.py ├── locale │ └── de │ │ └── LC_MESSAGES │ │ └── django.po ├── migrations.py ├── models.py ├── tasks.py ├── templates │ └── pictures │ │ ├── attrs.html │ │ └── picture.html ├── templatetags │ ├── __init__.py │ └── pictures.py ├── urls.py ├── utils.py ├── validators.py └── views.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── contrib ├── __init__.py ├── test_cleanup.py └── test_rest_framework.py ├── manage.py ├── test_checks.py ├── test_migrations.py ├── test_models.py ├── test_tasks.py ├── test_templatetags.py ├── test_utils.py ├── test_validators.py ├── test_views.py └── testapp ├── __init__.py ├── celery_app.py ├── migrations ├── 0001_initial.py ├── 0002_alter_profile_picture.py ├── 0003_validatormodel.py ├── 0004_jpegmodel.py ├── 0005_alter_profile_picture.py ├── 0006_profile_other_picture.py └── __init__.py ├── models.py ├── settings.py ├── templates └── testapp │ ├── base.html │ └── profile_detail.html ├── urls.py └── views.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | max_line_length = 88 13 | 14 | [*.{json,yml,yaml,js,jsx,vue,toml}] 15 | indent_size = 2 16 | 17 | [*.{html,htm,svg,xml}] 18 | indent_size = 2 19 | max_line_length = 120 20 | 21 | [LICENSE] 22 | insert_final_newline = false 23 | 24 | [*.{md,markdown}] 25 | indent_size = 2 26 | max_line_length = 80 27 | 28 | [Makefile] 29 | indent_style = tab 30 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: codingjoe 2 | buy_me_a_coffee: codingjoe 3 | tidelift: pypi/django-pictures 4 | custom: https://www.paypal.me/codingjoe 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: "/" 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | 11 | lint: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | lint-command: 16 | - bandit -r . -x ./tests 17 | - black --check --diff . 18 | - flake8 . 19 | - isort --check-only --diff . 20 | - pydocstyle . 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: "3.x" 26 | cache: 'pip' 27 | cache-dependency-path: 'pyproject.toml' 28 | - run: python -m pip install -e .[lint] 29 | - run: ${{ matrix.lint-command }} 30 | 31 | dist: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: actions/setup-python@v5 36 | with: 37 | python-version: "3.x" 38 | - run: sudo apt install gettext -y 39 | - run: python -m pip install --upgrade pip build wheel twine 40 | - run: make gettext 41 | - run: python -m build --sdist --wheel 42 | - run: python -m twine check dist/* 43 | - uses: actions/upload-artifact@v4 44 | with: 45 | path: dist/* 46 | 47 | pytest-os: 48 | name: PyTest 49 | needs: 50 | - lint 51 | strategy: 52 | matrix: 53 | os: 54 | - "ubuntu-latest" 55 | - "macos-latest" 56 | - "windows-latest" 57 | runs-on: ${{ matrix.os }} 58 | steps: 59 | - uses: actions/checkout@v4 60 | - name: Set up Python ${{ matrix.python-version }} 61 | uses: actions/setup-python@v5 62 | with: 63 | python-version: "3.x" 64 | - run: python -m pip install .[test] 65 | - run: python -m pytest 66 | - uses: codecov/codecov-action@v5 67 | with: 68 | flags: ${{ matrix.os }} 69 | token: ${{ secrets.CODECOV_TOKEN }} 70 | 71 | pytest-python: 72 | name: PyTest 73 | needs: 74 | - lint 75 | strategy: 76 | matrix: 77 | python-version: 78 | - "3.9" 79 | - "3.10" 80 | - "3.12" 81 | - "3.13" 82 | django-version: 83 | - "4.2" # LTS 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - name: Set up Python ${{ matrix.python-version }} 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: ${{ matrix.python-version }} 91 | - run: python -m pip install .[test] 92 | - run: python -m pip install django~=${{ matrix.django-version }}.0 93 | - run: python -m pytest 94 | - uses: codecov/codecov-action@v5 95 | with: 96 | flags: py${{ matrix.python-version }} 97 | token: ${{ secrets.CODECOV_TOKEN }} 98 | 99 | pytest-django: 100 | name: PyTest 101 | needs: 102 | - lint 103 | strategy: 104 | matrix: 105 | python-version: 106 | - "3.12" 107 | django-version: 108 | # LTS gets tested on all OS 109 | - "5.1" 110 | - "5.2" 111 | runs-on: ubuntu-latest 112 | steps: 113 | - uses: actions/checkout@v4 114 | - name: Set up Python ${{ matrix.python-version }} 115 | uses: actions/setup-python@v5 116 | with: 117 | python-version: ${{ matrix.python-version }} 118 | - run: python -m pip install .[test] 119 | - run: python -m pip install django~=${{ matrix.django-version }}.0 120 | - run: python -m pytest 121 | - uses: codecov/codecov-action@v5 122 | with: 123 | flags: dj${{ matrix.django-version }} 124 | token: ${{ secrets.CODECOV_TOKEN }} 125 | 126 | pytest-extras: 127 | name: PyTest 128 | needs: 129 | - lint 130 | strategy: 131 | matrix: 132 | extras: 133 | - "celery" 134 | - "dramatiq" 135 | - "django-rq" 136 | - "drf" 137 | - "cleanup" 138 | runs-on: ubuntu-latest 139 | steps: 140 | - uses: actions/checkout@v4 141 | - name: Set up Python ${{ matrix.python-version }} 142 | uses: actions/setup-python@v5 143 | with: 144 | python-version: 3.x 145 | - name: Install redis 146 | if: matrix.extras == 'dramatiq' || matrix.extras == 'django-rq' 147 | run: sudo apt install -y redis-server 148 | - run: python -m pip install .[test,${{ matrix.extras }}] 149 | - run: python -m pytest 150 | - uses: codecov/codecov-action@v5 151 | with: 152 | flags: ${{ matrix.extras }} 153 | token: ${{ secrets.CODECOV_TOKEN }} 154 | 155 | codeql: 156 | name: CodeQL 157 | needs: [ dist, pytest-os, pytest-python, pytest-django, pytest-extras ] 158 | runs-on: ubuntu-latest 159 | permissions: 160 | actions: read 161 | contents: read 162 | security-events: write 163 | strategy: 164 | fail-fast: false 165 | matrix: 166 | language: [ python ] 167 | steps: 168 | - name: Checkout 169 | uses: actions/checkout@v4 170 | - name: Initialize CodeQL 171 | uses: github/codeql-action/init@v3 172 | with: 173 | languages: ${{ matrix.language }} 174 | queries: +security-and-quality 175 | - name: Autobuild 176 | uses: github/codeql-action/autobuild@v3 177 | - name: Perform CodeQL Analysis 178 | uses: github/codeql-action/analyze@v3 179 | with: 180 | category: "/language:${{ matrix.language }}" 181 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | PyPi: 9 | 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.10" 16 | - run: sudo apt install gettext -y 17 | - run: python -m pip install --upgrade pip build wheel twine 18 | - run: make gettext 19 | - run: python -m build --sdist --wheel 20 | - run: python -m twine upload dist/* 21 | env: 22 | TWINE_USERNAME: __token__ 23 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # flit 132 | pictures/_version.py 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2022, Johannes Maron 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MSGLANGS = $(wildcard pictures/locale/*/LC_MESSAGES/*.po) 2 | MSGOBJS = $(MSGLANGS:.po=.mo) 3 | 4 | .PHONY: translations gettext gettext-clean 5 | 6 | gettext: $(MSGOBJS) 7 | 8 | gettext-clean: 9 | -rm $(MSGOBJS) 10 | 11 | %.mo: %.po 12 | msgfmt --check-format --check-domain --statistics -o $@ $*.po 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Django Pictures Logo](https://repository-images.githubusercontent.com/455480246/daaa7870-d28c-4fce-8296-d3e3af487a64) 2 | 3 | # Django Pictures 4 | 5 | Responsive cross-browser image library using modern codes like AVIF & WebP. 6 | 7 | - responsive web images using the [picture](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture) tag 8 | - native grid system support 9 | - serve files with or without a CDN 10 | - placeholders for local development 11 | - migration support 12 | - async image processing for [Celery], [Dramatiq] or [Django RQ][django-rq] 13 | - [DRF] support 14 | 15 | [![PyPi Version](https://img.shields.io/pypi/v/django-pictures.svg)](https://pypi.python.org/pypi/django-pictures/) 16 | [![Test Coverage](https://codecov.io/gh/codingjoe/django-pictures/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-pictures) 17 | [![GitHub License](https://img.shields.io/github/license/codingjoe/django-pictures)](https://raw.githubusercontent.com/codingjoe/django-pictures/master/LICENSE) 18 | 19 | ## Usage 20 | 21 | Before you start, it can be a good idea to understand the fundamentals of 22 | [responsive images](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images). 23 | 24 | Once you get a feeling how complicated things can get with all device types, 25 | you'll probably find a new appreciation for this package, 26 | and are ready to adopt in your project :) 27 | 28 | ```python 29 | # models.py 30 | from django.db import models 31 | from pictures.models import PictureField 32 | 33 | class Profile(models.Model): 34 | title = models.CharField(max_length=255) 35 | picture = PictureField(upload_to="avatars") 36 | ``` 37 | 38 | ```html 39 | 40 | {% load pictures %} 41 | {% picture profile.picture img_alt="Spiderman" img_loading="lazy" picture_class="my-picture" m=6 l=4 %} 42 | ``` 43 | 44 | The keyword arguments `m=6 l=4` define the columns the image should take up in 45 | a grid at a given breakpoint. So in this example, the image will take up 46 | six columns on medium screens and four columns on large screens. You can define 47 | your grid and breakpoints as you want, refer to the [grid columns](#grid-columns) and 48 | [breakpoints](#breakpoints) sections. 49 | 50 | The template above will render into: 51 | 52 | ```html 53 | 54 | 57 | Spiderman 58 | 59 | ``` 60 | 61 | Note that arbitrary attributes can be passed 62 | to either the `` or `` element 63 | by prefixing parameters to the `{% picture %}` tag 64 | with `picture_` or `img_` respectively. 65 | 66 | ## Setup 67 | 68 | ### Installation 69 | 70 | ```shell 71 | python3 -m pip install django-pictures 72 | ``` 73 | 74 | ### Settings 75 | 76 | ```python 77 | # settings.py 78 | INSTALLED_APPS = [ 79 | # ... 80 | 'pictures', 81 | ] 82 | 83 | # the following are defaults, but you can override them 84 | PICTURES = { 85 | "BREAKPOINTS": { 86 | "xs": 576, 87 | "s": 768, 88 | "m": 992, 89 | "l": 1200, 90 | "xl": 1400, 91 | }, 92 | "GRID_COLUMNS": 12, 93 | "CONTAINER_WIDTH": 1200, 94 | "FILE_TYPES": ["WEBP"], 95 | "PIXEL_DENSITIES": [1, 2], 96 | "USE_PLACEHOLDERS": True, 97 | "QUEUE_NAME": "pictures", 98 | "PROCESSOR": "pictures.tasks.process_picture", 99 | 100 | } 101 | ``` 102 | 103 | If you have either Dramatiq or Celery installed, we will default to async 104 | image processing. You will need workers to listen to the `pictures` queue. 105 | 106 | ### Placeholders 107 | 108 | This library comes with dynamically created placeholders to simplify local 109 | development. To enable them, add the following to enable the 110 | `PICTURES["USE_PLACEHOLDERS"]` setting and add the following URL configuration: 111 | 112 | ```python 113 | # urls.py 114 | from django.urls import include, path 115 | from pictures.conf import get_settings 116 | 117 | urlpatterns = [ 118 | # ... 119 | ] 120 | 121 | if get_settings().USE_PLACEHOLDERS: 122 | urlpatterns += [ 123 | path("_pictures/", include("pictures.urls")), 124 | ] 125 | ``` 126 | 127 | ### Legacy use-cases (email) 128 | 129 | Although the `picture`-tag is [adequate for most use-cases][caniuse-picture], 130 | some remain, where a single `img` tag is necessary. Notably in email, where 131 | [most clients do support WebP][caniemail-webp] but not [srcset][caniemail-srcset]. 132 | The template tag `img_url` returns a single size image URL. 133 | In addition to the ratio, you will need to define the `file_type` 134 | as well as the `width` (absolute width in pixels). 135 | 136 | ```html 137 | {% load pictures %} 138 | profile picture 139 | ``` 140 | 141 | ## Config 142 | 143 | ### Aspect ratios 144 | 145 | You can specify the aspect ratios of your images. Images will be cropped to the 146 | specified aspect ratio. Aspect ratios are specified as a string with a slash 147 | between the width and height. For example, `16/9` will crop the image to 16:9. 148 | 149 | ```python 150 | # models.py 151 | from django.db import models 152 | from pictures.models import PictureField 153 | 154 | 155 | class Profile(models.Model): 156 | title = models.CharField(max_length=255) 157 | picture = PictureField( 158 | upload_to="avatars", 159 | aspect_ratios=[None, "1/1", "3/2", "16/9"], 160 | ) 161 | ``` 162 | 163 | ```html 164 | # template.html 165 | {% load pictures %} 166 | {% picture profile.picture img_alt="Spiderman" ratio="16/9" m=6 l=4 %} 167 | ``` 168 | 169 | If you don't specify an aspect ratio or None in your template, the image will be 170 | served with the original aspect ratio of the file. 171 | 172 | You may only use aspect ratios in templates that have been defined on the model. 173 | The model `aspect_ratios` will default to `[None]`, if other value is provided. 174 | 175 | ### Breakpoints 176 | 177 | You may define your own breakpoints they should be identical to the ones used 178 | in your CSS library. This can be achieved by overriding the `PICTURES["BREAKPOINTS"]` setting. 179 | 180 | ### Grid columns 181 | 182 | Grids are so common in web design that they even made it into CSS. 183 | We default to 12 columns, but you can override this setting, via the 184 | `PICTURES["GRID_COLUMNS"]` setting. 185 | 186 | ### Container width 187 | 188 | Containers are commonly used to limit the maximum width of layouts, 189 | to promote better readability on larger screens. We default to `1200px`, 190 | but you can override this setting, via the `PICTURES["CONTAINER_WIDTH"]` setting. 191 | 192 | You may also set it to `None`, should you not use a container. 193 | 194 | ### File types 195 | 196 | [AVIF](https://caniuse.com/avif) ([WebP](https://caniuse.com/webp)'s successor) 197 | is the best and most efficient image format available today. It is part of 198 | Baseline 2024 and is supported by all major browsers. Additionally, most modern 199 | devices will have hardware acceleration for AVIF decoding. This will not only 200 | reduce network IO but speed up page rendering. 201 | 202 | > [!NOTE] 203 | > Pillow 11.2.1 shipped without AVIF binaries. 204 | > You will need to [install Pillow from source][libavif-install] for AVIF support. 205 | > This should be resolved in upcoming releases, once 206 | > [#8858](https://github.com/python-pillow/Pillow/pull/8858) has been merged. 207 | 208 | Should you still serve IE11, use add `JPEG` to the list. But, beware, this may 209 | drastically increase your storage needs. 210 | 211 | ### Pixel densities 212 | 213 | Unless you really care that your images hold of if you hold your UHD phone very 214 | close to your eyeballs, you should be fine, serving at the default `1x` and `2x` 215 | densities. 216 | 217 | ### Async image processing 218 | 219 | If you have either Dramatiq or Celery installed, we will default to async 220 | image processing. You will need workers to listen to the `pictures` queue. 221 | You can override the queue name, via the `PICTURES["QUEUE_NAME"]` setting. 222 | 223 | You can also override the processor, via the `PICTURES["PROCESSOR"]` setting. 224 | The default processor is `pictures.tasks.process_picture`. It takes a single 225 | argument, the `PictureFileFile` instance. You can use this to override the 226 | processor, should you need to do some custom processing. 227 | 228 | ## Migrations 229 | 230 | Django doesn't support file field migrations, but we do. 231 | You can auto create the migration and replace Django's 232 | `AlterField` operation with `AlterPictureField`. That's it. 233 | 234 | You can follow [the example][migration] in our test app, to see how it works. 235 | 236 | ## Contrib 237 | 238 | ### Django Rest Framework ([DRF]) 239 | 240 | We do ship with a read-only `PictureField` that can be used to include all 241 | available picture sizes in a DRF serializer. 242 | 243 | ```python 244 | from rest_framework import serializers 245 | from pictures.contrib.rest_framework import PictureField 246 | 247 | class PictureSerializer(serializers.Serializer): 248 | picture = PictureField() 249 | ``` 250 | 251 | The response can be restricted to a fewer aspect ratios and file types, by 252 | providing the `aspect_ratios` and `file_types` arguments to the DRF field. 253 | 254 | ```python 255 | from rest_framework import serializers 256 | from pictures.contrib.rest_framework import PictureField 257 | 258 | class PictureSerializer(serializers.Serializer): 259 | picture = PictureField(aspect_ratios=["16/9"], file_types=["WEBP"]) 260 | ``` 261 | 262 | You also may provide optional GET parameters to the serializer 263 | to specify the aspect ratio and breakpoints you want to include in the response. 264 | The parameters are prefixed with the `fieldname_` 265 | to avoid conflicts with other fields. 266 | 267 | ```bash 268 | curl http://localhost:8000/api/path/?picture_ratio=16%2F9&picture_m=6&picture_l=4 269 | # %2F is the url encoded slash 270 | ``` 271 | 272 | ```json 273 | { 274 | "other_fields": "…", 275 | "picture": { 276 | "url": "/path/to/image.jpg", 277 | "width": 800, 278 | "height": 800, 279 | "ratios": { 280 | "1/1": { 281 | "sources": { 282 | "image/webp": { 283 | "100": "/path/to/image/1/100w.webp", 284 | "200": "…" 285 | } 286 | }, 287 | "media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 33vw, 25vw" 288 | } 289 | } 290 | } 291 | } 292 | ``` 293 | 294 | Note that the `media` keys are only included, if you have specified breakpoints. 295 | 296 | ### Django Cleanup 297 | 298 | `PictureField` is compatible with [Django Cleanup](https://github.com/un1t/django-cleanup), 299 | which automatically deletes its file and corresponding `SimplePicture` files. 300 | 301 | ### external image processing (via CDNs) 302 | 303 | This package is designed to accommodate growth, allowing you to start small and scale up as needed. 304 | Should you use a CDN, or some other external image processing service, you can 305 | set this up in two simple steps: 306 | 307 | 1. Override `PICTURES["PROCESSOR"]` to disable the default processing. 308 | 1. Override `PICTURES["PICTURE_CLASS"]` implement any custom behavior. 309 | 310 | ```python 311 | # settings.py 312 | PICTURES = { 313 | "PROCESSOR": "pictures.tasks.noop", # disable default processing and do nothing 314 | "PICTURE_CLASS": "path.to.MyPicture", # override the default picture class 315 | } 316 | ``` 317 | 318 | The `MyPicture`class should implement the url property, which returns the URL 319 | of the image. You may use the `Picture` class as a base class. 320 | 321 | Available attributes are: 322 | 323 | - `parent_name` - name of the source file uploaded to the `PictureField` 324 | - `aspect_ratio` - aspect ratio of the output image 325 | - `width` - width of the output image 326 | - `file_type` - format of the output image 327 | 328 | ```python 329 | # path/to.py 330 | from pathlib import Path 331 | from pictures.models import Picture 332 | 333 | 334 | class MyPicture(Picture): 335 | @property 336 | def url(self): 337 | return ( 338 | f"https://cdn.example.com/{Path(self.parent_name).stem}" 339 | f"_{self.aspect_ratio}_{self.width}w.{self.file_type.lower()}" 340 | ) 341 | ``` 342 | 343 | [caniemail-srcset]: https://www.caniemail.com/features/html-srcset/ 344 | [caniemail-webp]: https://www.caniemail.com/features/image-webp/ 345 | [caniuse-picture]: https://caniuse.com/picture 346 | [celery]: https://docs.celeryproject.org/en/stable/ 347 | [django-rq]: https://github.com/rq/django-rq 348 | [dramatiq]: https://dramatiq.io/ 349 | [drf]: https://www.django-rest-framework.org/ 350 | [libavif-install]: https://pillow.readthedocs.io/en/latest/installation/building-from-source.html#external-libraries 351 | [migration]: tests/testapp/migrations/0002_alter_profile_picture.py 352 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Security contact information 4 | 5 | To report a security vulnerability, please use the 6 | [Tidelift security contact](https://tidelift.com/security). 7 | Tidelift will coordinate the fix and disclosure. 8 | -------------------------------------------------------------------------------- /pictures/__init__.py: -------------------------------------------------------------------------------- 1 | """Responsive cross-browser image library using modern codes like AVIF & WebP.""" 2 | 3 | from . import _version 4 | 5 | __version__ = _version.version 6 | VERSION = _version.version_tuple 7 | -------------------------------------------------------------------------------- /pictures/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PicturesAppConfig(AppConfig): 5 | name = "pictures" 6 | 7 | def ready(self): 8 | import pictures.checks # noqa 9 | -------------------------------------------------------------------------------- /pictures/checks.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, Tags, register 2 | from django.urls import NoReverseMatch, reverse 3 | 4 | from . import conf 5 | 6 | __all__ = ["placeholder_url_check"] 7 | 8 | 9 | @register(Tags.urls) 10 | def placeholder_url_check(app_configs, **kwargs): 11 | errors = [] 12 | if conf.get_settings().USE_PLACEHOLDERS: 13 | try: 14 | reverse( 15 | "pictures:placeholder", 16 | kwargs={ 17 | "alt": "test", 18 | "width": 100, 19 | "ratio": "1x1", 20 | "file_type": "jpg", 21 | }, 22 | ) 23 | except NoReverseMatch: 24 | errors.append( 25 | Error( 26 | "Placeholder URLs are not configured correctly.", 27 | hint=( 28 | 'PICTURES["USE_PLACEHOLDERS"] is True,' 29 | ' but include("pictures.urls") is missing for your URL config.' 30 | ), 31 | id="pictures.E001", 32 | ) 33 | ) 34 | return errors 35 | -------------------------------------------------------------------------------- /pictures/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | 5 | 6 | def get_settings(): 7 | return type( 8 | "Settings", 9 | (), 10 | { 11 | "BREAKPOINTS": { 12 | "xs": 576, 13 | "s": 768, 14 | "m": 992, 15 | "l": 1200, 16 | "xl": 1400, 17 | }, 18 | "GRID_COLUMNS": 12, 19 | "CONTAINER_WIDTH": 1200, 20 | "FILE_TYPES": ["WEBP"], 21 | "PIXEL_DENSITIES": [1, 2], 22 | "USE_PLACEHOLDERS": settings.DEBUG, 23 | "QUEUE_NAME": "pictures", 24 | "PICTURE_CLASS": "pictures.models.PillowPicture", 25 | "PROCESSOR": "pictures.tasks.process_picture", 26 | **getattr(settings, "PICTURES", {}), 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /pictures/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-pictures/fcc1eee502754c1d69f55cd7128bb8e8c2503a69/pictures/contrib/__init__.py -------------------------------------------------------------------------------- /pictures/contrib/rest_framework.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import QueryDict 4 | from rest_framework import serializers 5 | 6 | __all__ = ["PictureField"] 7 | 8 | from pictures import utils 9 | from pictures.models import Picture, PictureFieldFile 10 | 11 | 12 | def default(obj): 13 | if isinstance(obj, Picture): 14 | return obj.url 15 | raise TypeError(f"Type '{type(obj).__name__}' not serializable") 16 | 17 | 18 | class PictureField(serializers.ReadOnlyField): 19 | """Read-only field for all aspect ratios and sizes of the image.""" 20 | 21 | def __init__(self, aspect_ratios=None, file_types=None, **kwargs): 22 | super().__init__(**kwargs) 23 | self.aspect_ratios = aspect_ratios or [] 24 | self.file_types = file_types or [] 25 | 26 | def to_representation(self, obj: PictureFieldFile): 27 | if not obj: 28 | return None 29 | 30 | base_payload = { 31 | "url": obj.url, 32 | "width": obj.width, 33 | "height": obj.height, 34 | } 35 | field = obj.field 36 | 37 | # if the request has query parameters, filter the payload 38 | try: 39 | query_params: QueryDict = self.context["request"].GET 40 | except KeyError: 41 | ratios = self.aspect_ratios 42 | container = field.container_width 43 | breakpoints = {} 44 | else: 45 | ratios = ( 46 | query_params.getlist(f"{self.field_name}_ratio") 47 | ) or self.aspect_ratios 48 | container = query_params.get(f"{self.field_name}_container") 49 | try: 50 | container = int(container) 51 | except TypeError: 52 | container = field.container_width 53 | except ValueError as e: 54 | raise ValueError(f"Container width is not a number: {container}") from e 55 | breakpoints = { 56 | bp: int(query_params.get(f"{self.field_name}_{bp}")) 57 | for bp in field.breakpoints 58 | if f"{self.field_name}_{bp}" in query_params 59 | } 60 | if set(ratios) - set(self.aspect_ratios or obj.aspect_ratios.keys()): 61 | raise ValueError( 62 | f"Invalid ratios: {', '.join(ratios)}. Choices are: {', '.join(filter(None, obj.aspect_ratios.keys()))}" 63 | ) 64 | 65 | payload = { 66 | **base_payload, 67 | "ratios": { 68 | ratio: { 69 | "sources": { 70 | f"image/{file_type.lower()}": sizes 71 | for file_type, sizes in sources.items() 72 | if file_type in self.file_types or not self.file_types 73 | }, 74 | "media": utils.sizes( 75 | field=field, container_width=container, **breakpoints 76 | ), 77 | } 78 | for ratio, sources in obj.aspect_ratios.items() 79 | if ratio in ratios or not ratios 80 | }, 81 | } 82 | 83 | return json.loads(json.dumps(payload, default=default)) 84 | -------------------------------------------------------------------------------- /pictures/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Django-Pictures 2 | # Copyright (C) 2022 Johannes Maron 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Johannes Maron , 2022. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-08-06 13:58+0200\n" 11 | "PO-Revision-Date: 2022-08-06 14:14+0200\n" 12 | "Last-Translator: Johannes Maron \n" 13 | "Language-Team: \n" 14 | "Language: de\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | "X-Generator: Poedit 3.1.1\n" 20 | 21 | #: validators.py:47 22 | #, python-format 23 | msgid "" 24 | "The image you uploaded is too large. The required maximum resolution is: " 25 | "%(width)sx%(height)s px." 26 | msgstr "" 27 | "Das von Ihnen hochgeladene Bild ist zu groß. Die erforderliche maximale " 28 | "Auflösung beträgt: %(width)sx%(height)s px." 29 | 30 | #: validators.py:65 31 | #, python-format 32 | msgid "" 33 | "The image you uploaded is too small. The required minimum resolution is: " 34 | "%(width)sx%(height)s px." 35 | msgstr "" 36 | "Das von Ihnen hochgeladene Bild ist zu klein. Die erforderliche " 37 | "Mindestauflösung beträgt: %(width)sx%(height)s px." 38 | -------------------------------------------------------------------------------- /pictures/migrations.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from django.db import models 4 | from django.db.migrations import AlterField 5 | from django.db.models import Q 6 | 7 | from pictures.models import PictureField 8 | 9 | __all__ = ["AlterPictureField"] 10 | 11 | 12 | class AlterPictureField(AlterField): 13 | """Alter field schema and render or remove picture sizes.""" 14 | 15 | reduces_to_sql = False 16 | reversible = True 17 | 18 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 19 | super().database_forwards(app_label, schema_editor, from_state, to_state) 20 | from_model = from_state.apps.get_model(app_label, self.model_name) 21 | to_model = to_state.apps.get_model(app_label, self.model_name) 22 | self.alter_picture_field(from_model, to_model) 23 | 24 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 25 | super().database_backwards(app_label, schema_editor, from_state, to_state) 26 | from_model = from_state.apps.get_model(app_label, self.model_name) 27 | to_model = to_state.apps.get_model(app_label, self.model_name) 28 | self.alter_picture_field(from_model, to_model) 29 | 30 | def alter_picture_field( 31 | self, from_model: Type[models.Model], to_model: Type[models.Model] 32 | ): 33 | from_field = from_model._meta.get_field(self.name) 34 | to_field = to_model._meta.get_field(self.name) 35 | 36 | if not isinstance(from_field, PictureField) and isinstance( 37 | to_field, PictureField 38 | ): 39 | self.to_picture_field(from_model, to_model) 40 | elif isinstance(from_field, PictureField) and not isinstance( 41 | to_field, PictureField 42 | ): 43 | self.from_picture_field(from_model) 44 | elif isinstance(from_field, PictureField) and isinstance( 45 | to_field, PictureField 46 | ): 47 | self.update_pictures(from_field, to_model) 48 | 49 | def update_pictures(self, from_field: PictureField, to_model: Type[models.Model]): 50 | """Remove obsolete pictures and create new ones.""" 51 | for obj in to_model._default_manager.exclude( 52 | Q(**{self.name: ""}) | Q(**{self.name: None}) 53 | ).iterator(): 54 | new_field_file = getattr(obj, self.name) 55 | old_field_file = from_field.attr_class( 56 | instance=obj, field=from_field, name=new_field_file.name 57 | ) 58 | new_field_file.update_all(old_field_file) 59 | 60 | def from_picture_field(self, from_model: Type[models.Model]): 61 | for obj in from_model._default_manager.all().iterator(): 62 | field_file = getattr(obj, self.name) 63 | field_file.delete_all() 64 | 65 | def to_picture_field( 66 | self, from_model: Type[models.Model], to_model: Type[models.Model] 67 | ): 68 | from_field = from_model._meta.get_field(self.name) 69 | if hasattr(from_field.attr_class, "delete_variations"): 70 | # remove obsolete django-stdimage variations 71 | for obj in from_model._default_manager.all().iterator(): 72 | field_file = getattr(obj, self.name) 73 | field_file.delete_variations() 74 | for obj in to_model._default_manager.all().iterator(): 75 | field_file = getattr(obj, self.name) 76 | field_file.save_all() 77 | -------------------------------------------------------------------------------- /pictures/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import dataclasses 5 | import io 6 | import math 7 | from fractions import Fraction 8 | from pathlib import Path 9 | 10 | from django.core import checks 11 | from django.core.files.base import ContentFile 12 | from django.core.files.storage import Storage 13 | from django.db.models import ImageField 14 | from django.db.models.fields.files import ImageFieldFile 15 | from django.urls import reverse 16 | from django.utils.module_loading import import_string 17 | from PIL import Image, ImageOps 18 | 19 | from pictures import conf, utils 20 | 21 | __all__ = ["PictureField", "PictureFieldFile", "Picture"] 22 | 23 | RGB_FORMATS = ["JPEG"] 24 | 25 | 26 | @dataclasses.dataclass 27 | class Picture(abc.ABC): 28 | """ 29 | An abstract picture class similar to Django's image class. 30 | 31 | Subclasses will need to implement the `url` property. 32 | """ 33 | 34 | parent_name: str 35 | file_type: str 36 | aspect_ratio: str | Fraction | None 37 | storage: Storage 38 | width: int 39 | 40 | def __post_init__(self): 41 | self.aspect_ratio = Fraction(self.aspect_ratio) if self.aspect_ratio else None 42 | 43 | def __hash__(self): 44 | return hash(self.url) 45 | 46 | def __eq__(self, other): 47 | if not isinstance(other, type(self)): 48 | return NotImplemented 49 | return self.deconstruct() == other.deconstruct() 50 | 51 | def deconstruct(self): 52 | return ( 53 | f"{self.__class__.__module__}.{self.__class__.__qualname__}", 54 | ( 55 | self.parent_name, 56 | self.file_type, 57 | str(self.aspect_ratio) if self.aspect_ratio else None, 58 | self.storage.deconstruct(), 59 | self.width, 60 | ), 61 | {}, 62 | ) 63 | 64 | @property 65 | @abc.abstractmethod 66 | def url(self) -> str: 67 | """Return the URL of the picture.""" 68 | 69 | 70 | class PillowPicture(Picture): 71 | """Use the Pillow library to process images.""" 72 | 73 | @property 74 | def url(self) -> str: 75 | if conf.get_settings().USE_PLACEHOLDERS: 76 | return reverse( 77 | "pictures:placeholder", 78 | kwargs={ 79 | "alt": Path(self.parent_name).stem, 80 | "width": self.width, 81 | "ratio": ( 82 | f"{self.aspect_ratio.numerator}x{self.aspect_ratio.denominator}" 83 | if self.aspect_ratio 84 | else None 85 | ), 86 | "file_type": self.file_type, 87 | }, 88 | ) 89 | return self.storage.url(self.name) 90 | 91 | @property 92 | def height(self) -> int | None: 93 | if self.aspect_ratio: 94 | return math.floor(self.width / self.aspect_ratio) 95 | 96 | @property 97 | def name(self) -> str: 98 | path = Path(self.parent_name).with_suffix("") 99 | if self.aspect_ratio: 100 | path /= str(self.aspect_ratio).replace("/", "_") 101 | return str(path / f"{self.width}w.{self.file_type.lower()}") 102 | 103 | @property 104 | def path(self) -> Path: 105 | return Path(self.storage.path(self.name)) 106 | 107 | def process(self, image) -> Image: 108 | image = ImageOps.exif_transpose(image) # crates a copy 109 | height = self.height or self.width / Fraction(*image.size) 110 | size = math.floor(self.width), math.floor(height) 111 | 112 | if self.aspect_ratio: 113 | image = ImageOps.fit(image, size) 114 | else: 115 | image.thumbnail(size) 116 | return image 117 | 118 | def save(self, image): 119 | with io.BytesIO() as file_buffer: 120 | img = self.process(image) 121 | if (self.file_type in RGB_FORMATS) and (img.mode != "RGB"): 122 | img = img.convert("RGB") 123 | img.save(file_buffer, format=self.file_type) 124 | self.storage.delete(self.name) # avoid any filename collisions 125 | self.storage.save(self.name, ContentFile(file_buffer.getvalue())) 126 | 127 | def delete(self): 128 | self.storage.delete(self.name) 129 | 130 | 131 | class PictureFieldFile(ImageFieldFile): 132 | 133 | def __xor__(self, other) -> tuple[set[Picture], set[Picture]]: 134 | """Return the new and obsolete :class:`Picture` instances.""" 135 | if not isinstance(other, PictureFieldFile): 136 | return NotImplemented 137 | new = self.get_picture_files_list() - other.get_picture_files_list() 138 | obsolete = other.get_picture_files_list() - self.get_picture_files_list() 139 | 140 | return new, obsolete 141 | 142 | def save(self, name, content, save=True): 143 | super().save(name, content, save) 144 | self.save_all() 145 | 146 | def save_all(self): 147 | self.update_all() 148 | 149 | def delete(self, save=True): 150 | self.delete_all() 151 | super().delete(save=save) 152 | 153 | def delete_all(self): 154 | if self: 155 | import_string(conf.get_settings().PROCESSOR)( 156 | self.storage.deconstruct(), 157 | self.name, 158 | [], 159 | [i.deconstruct() for i in self.get_picture_files_list()], 160 | ) 161 | 162 | def update_all(self, other: PictureFieldFile | None = None): 163 | if self: 164 | if not other: 165 | new = self.get_picture_files_list() 166 | old = [] 167 | else: 168 | new, old = self ^ other 169 | import_string(conf.get_settings().PROCESSOR)( 170 | self.storage.deconstruct(), 171 | self.name, 172 | [i.deconstruct() for i in new], 173 | [i.deconstruct() for i in old], 174 | ) 175 | 176 | @property 177 | def width(self): 178 | self._require_file() 179 | if self._committed and self.field.width_field: 180 | if width := getattr(self.instance, self.field.width_field, None): 181 | # get width from width field, to avoid loading image 182 | return width 183 | return self._get_image_dimensions()[0] 184 | 185 | @property 186 | def height(self): 187 | self._require_file() 188 | if self._committed and self.field.height_field: 189 | if height := getattr(self.instance, self.field.height_field, None): 190 | # get height from height field, to avoid loading image 191 | return height 192 | return self._get_image_dimensions()[1] 193 | 194 | @property 195 | def aspect_ratios(self) -> {Fraction | None: {str: {int: Picture}}}: 196 | self._require_file() 197 | return self.get_picture_files( 198 | file_name=self.name, 199 | img_width=self.width, 200 | img_height=self.height, 201 | storage=self.storage, 202 | field=self.field, 203 | ) 204 | 205 | @staticmethod 206 | def get_picture_files( 207 | *, 208 | file_name: str, 209 | img_width: int, 210 | img_height: int, 211 | storage: Storage, 212 | field: PictureField, 213 | ) -> {Fraction | None: {str: {int: Picture}}}: 214 | PictureClass = import_string(conf.get_settings().PICTURE_CLASS) 215 | return { 216 | ratio: { 217 | file_type: { 218 | width: PictureClass(file_name, file_type, ratio, storage, width) 219 | for width in utils.source_set( 220 | (img_width, img_height), 221 | ratio=ratio, 222 | max_width=field.container_width, 223 | cols=field.grid_columns, 224 | ) 225 | } 226 | for file_type in field.file_types 227 | } 228 | for ratio in field.aspect_ratios 229 | } 230 | 231 | def get_picture_files_list(self) -> set[Picture]: 232 | return { 233 | picture 234 | for sources in self.aspect_ratios.values() 235 | for srcset in sources.values() 236 | for picture in srcset.values() 237 | } 238 | 239 | 240 | class PictureField(ImageField): 241 | attr_class = PictureFieldFile 242 | 243 | def __init__( 244 | self, 245 | verbose_name=None, 246 | name=None, 247 | aspect_ratios: [str | Fraction | None] = None, 248 | container_width: int = None, 249 | file_types: [str] = None, 250 | pixel_densities: [int] = None, 251 | grid_columns: int = None, 252 | breakpoints: {str: int} = None, 253 | **kwargs, 254 | ): 255 | settings = conf.get_settings() 256 | self.aspect_ratios = aspect_ratios or [None] 257 | self.container_width = container_width or settings.CONTAINER_WIDTH 258 | self.file_types = file_types or settings.FILE_TYPES 259 | self.pixel_densities = pixel_densities or settings.PIXEL_DENSITIES 260 | self.grid_columns = grid_columns or settings.GRID_COLUMNS 261 | self.breakpoints = breakpoints or settings.BREAKPOINTS 262 | super().__init__( 263 | verbose_name=verbose_name, 264 | name=name, 265 | **kwargs, 266 | ) 267 | 268 | def check(self, **kwargs): 269 | return ( 270 | super().check(**kwargs) 271 | + self._check_aspect_ratios() 272 | + self._check_width_height_field() 273 | ) 274 | 275 | def _check_aspect_ratios(self): 276 | errors = [] 277 | if self.aspect_ratios: 278 | for ratio in self.aspect_ratios: 279 | if ratio is not None: 280 | try: 281 | Fraction(ratio) 282 | except ValueError: 283 | errors.append( 284 | checks.Error( 285 | "Invalid aspect ratio", 286 | obj=self, 287 | id="fields.E100", 288 | hint="Aspect ratio must be a fraction, e.g. '16/9'", 289 | ) 290 | ) 291 | return errors 292 | 293 | def _check_width_height_field(self): 294 | if None in self.aspect_ratios and not (self.width_field and self.height_field): 295 | return [ 296 | checks.Warning( 297 | "width_field and height_field attributes are missing", 298 | obj=self, 299 | id="fields.E101", 300 | hint=f"Please add two positive integer fields to '{self.model._meta.app_label}.{self.model.__name__}' and add their field names as the 'width_field' and 'height_field' attribute for your picture field. Otherwise Django will not be able to cache the image aspect size causing disk IO and potential response time increases.", 301 | ) 302 | ] 303 | return [] 304 | 305 | def deconstruct(self): 306 | name, path, args, kwargs = super().deconstruct() 307 | return ( 308 | name, 309 | path, 310 | args, 311 | { 312 | **kwargs, 313 | "aspect_ratios": self.aspect_ratios, 314 | "container_width": self.container_width, 315 | "file_types": self.file_types, 316 | "pixel_densities": self.pixel_densities, 317 | "grid_columns": self.grid_columns, 318 | "breakpoints": self.breakpoints, 319 | }, 320 | ) 321 | -------------------------------------------------------------------------------- /pictures/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol 4 | 5 | from django.db import transaction 6 | from PIL import Image 7 | 8 | from pictures import conf, utils 9 | 10 | 11 | def noop(*args, **kwargs) -> None: 12 | """Do nothing. You will need to set up your own image processing (like a CDN).""" 13 | 14 | 15 | class PictureProcessor(Protocol): 16 | 17 | def __call__( 18 | self, 19 | storage: tuple[str, list, dict], 20 | file_name: str, 21 | new: list[tuple[str, list, dict]] | None = None, 22 | old: list[tuple[str, list, dict]] | None = None, 23 | ) -> None: ... 24 | 25 | 26 | def _process_picture( 27 | storage: tuple[str, list, dict], 28 | file_name: str, 29 | new: list[tuple[str, list, dict]] | None = None, 30 | old: list[tuple[str, list, dict]] | None = None, 31 | ) -> None: 32 | new = new or [] 33 | old = old or [] 34 | storage = utils.reconstruct(*storage) 35 | if new: 36 | with storage.open(file_name) as fs: 37 | with Image.open(fs) as img: 38 | for picture in new: 39 | picture = utils.reconstruct(*picture) 40 | picture.save(img) 41 | 42 | for picture in old: 43 | picture = utils.reconstruct(*picture) 44 | picture.delete() 45 | 46 | 47 | process_picture: PictureProcessor = _process_picture 48 | 49 | 50 | try: 51 | import dramatiq 52 | except ImportError: 53 | pass 54 | else: 55 | 56 | @dramatiq.actor(queue_name=conf.get_settings().QUEUE_NAME) 57 | def process_picture_with_dramatiq( 58 | storage: tuple[str, list, dict], 59 | file_name: str, 60 | new: list[tuple[str, list, dict]] | None = None, 61 | old: list[tuple[str, list, dict]] | None = None, 62 | ) -> None: 63 | _process_picture(storage, file_name, new, old) 64 | 65 | def process_picture( # noqa: F811 66 | storage: tuple[str, list, dict], 67 | file_name: str, 68 | new: list[tuple[str, list, dict]] | None = None, 69 | old: list[tuple[str, list, dict]] | None = None, 70 | ) -> None: 71 | transaction.on_commit( 72 | lambda: process_picture_with_dramatiq.send( 73 | storage=storage, 74 | file_name=file_name, 75 | new=new, 76 | old=old, 77 | ) 78 | ) 79 | 80 | 81 | try: 82 | from celery import shared_task 83 | except ImportError: 84 | pass 85 | else: 86 | 87 | @shared_task( 88 | ignore_results=True, 89 | retry_backoff=True, 90 | ) 91 | def process_picture_with_celery( 92 | storage: tuple[str, list, dict], 93 | file_name: str, 94 | new: list[tuple[str, list, dict]] | None = None, 95 | old: list[tuple[str, list, dict]] | None = None, 96 | ) -> None: 97 | _process_picture(storage, file_name, new, old) 98 | 99 | def process_picture( # noqa: F811 100 | storage: tuple[str, list, dict], 101 | file_name: str, 102 | new: list[tuple[str, list, dict]] | None = None, 103 | old: list[tuple[str, list, dict]] | None = None, 104 | ) -> None: 105 | transaction.on_commit( 106 | lambda: process_picture_with_celery.apply_async( 107 | kwargs=dict( 108 | storage=storage, 109 | file_name=file_name, 110 | new=new, 111 | old=old, 112 | ), 113 | queue=conf.get_settings().QUEUE_NAME, 114 | ) 115 | ) 116 | 117 | 118 | try: 119 | from django_rq import job 120 | except ImportError: 121 | pass 122 | else: 123 | 124 | @job(conf.get_settings().QUEUE_NAME) 125 | def process_picture_with_django_rq( 126 | storage: tuple[str, list, dict], 127 | file_name: str, 128 | new: list[tuple[str, list, dict]] | None = None, 129 | old: list[tuple[str, list, dict]] | None = None, 130 | ) -> None: 131 | _process_picture(storage, file_name, new, old) 132 | 133 | def process_picture( # noqa: F811 134 | storage: tuple[str, list, dict], 135 | file_name: str, 136 | new: list[tuple[str, list, dict]] | None = None, 137 | old: list[tuple[str, list, dict]] | None = None, 138 | ) -> None: 139 | transaction.on_commit( 140 | lambda: process_picture_with_django_rq.delay( 141 | storage=storage, 142 | file_name=file_name, 143 | new=new, 144 | old=old, 145 | ) 146 | ) 147 | -------------------------------------------------------------------------------- /pictures/templates/pictures/attrs.html: -------------------------------------------------------------------------------- 1 | {% for name, value in attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %} -------------------------------------------------------------------------------- /pictures/templates/pictures/picture.html: -------------------------------------------------------------------------------- 1 | {% for type, srcset in sources.items %} 2 | {% endfor %} 5 | 6 | 7 | -------------------------------------------------------------------------------- /pictures/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-pictures/fcc1eee502754c1d69f55cd7128bb8e8c2503a69/pictures/templatetags/__init__.py -------------------------------------------------------------------------------- /pictures/templatetags/pictures.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django import template 4 | from django.template import loader 5 | 6 | from .. import utils 7 | from ..conf import get_settings 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.simple_tag() 13 | def picture(field_file, img_alt=None, ratio=None, container=None, **kwargs): 14 | settings = get_settings() 15 | field = field_file.field 16 | container = container or field.container_width 17 | tmpl = loader.get_template("pictures/picture.html") 18 | breakpoints = {} 19 | picture_attrs = {} 20 | img_attrs = { 21 | "src": field_file.url, 22 | "alt": img_alt, 23 | "width": field_file.width, 24 | "height": field_file.height, 25 | } 26 | try: 27 | sources = field_file.aspect_ratios[ratio] 28 | except KeyError as e: 29 | raise ValueError( 30 | f"Invalid ratio: {ratio}. Choices are: {', '.join(filter(None, field_file.aspect_ratios.keys()))}" 31 | ) from e 32 | for key, value in kwargs.items(): 33 | if key in field.breakpoints: 34 | breakpoints[key] = value 35 | elif key.startswith("picture_"): 36 | picture_attrs[key[8:]] = value 37 | elif key.startswith("img_"): 38 | img_attrs[key[4:]] = value 39 | else: 40 | raise TypeError(f"Invalid keyword argument: {key}") 41 | return tmpl.render( 42 | { 43 | "field_file": field_file, 44 | "alt": img_alt, 45 | "ratio": (ratio or "3/2").replace("/", "x"), 46 | "sources": sources, 47 | "media": utils.sizes(field=field, container_width=container, **breakpoints), 48 | "picture_attrs": picture_attrs, 49 | "img_attrs": img_attrs, 50 | "use_placeholders": settings.USE_PLACEHOLDERS, 51 | } 52 | ) 53 | 54 | 55 | @register.simple_tag() 56 | def img_url(field_file, file_type, width, ratio=None) -> str: 57 | """ 58 | Return the URL for a specific image file. 59 | 60 | This may be useful for use-cases like emails, where you can't use a picture tag. 61 | """ 62 | try: 63 | file_types = field_file.aspect_ratios[ratio] 64 | except KeyError as e: 65 | raise ValueError( 66 | f"Invalid ratio: {ratio}. Choices are: {', '.join(filter(None, field_file.aspect_ratios.keys()))}" 67 | ) from e 68 | try: 69 | sizes = file_types[file_type.upper()] 70 | except KeyError as e: 71 | raise ValueError( 72 | f"Invalid file type: {file_type}. Choices are: {', '.join(file_types.keys())}" 73 | ) from e 74 | url = field_file.url 75 | if not sizes.items(): 76 | warnings.warn( 77 | "Image is smaller than requested size, using source file URL.", 78 | stacklevel=2, 79 | ) 80 | for w, img in sorted(sizes.items()): 81 | url = img.url 82 | if w >= int(width): 83 | break 84 | return url 85 | -------------------------------------------------------------------------------- /pictures/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "pictures" 6 | 7 | urlpatterns = [ 8 | path( 9 | "//w.", 10 | views.placeholder, 11 | name="placeholder", 12 | ), 13 | ] 14 | -------------------------------------------------------------------------------- /pictures/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | import random 5 | import sys 6 | import warnings 7 | from fractions import Fraction 8 | from functools import lru_cache 9 | from urllib.parse import unquote 10 | 11 | from PIL import Image, ImageDraw, ImageFont 12 | 13 | from . import conf 14 | 15 | __all__ = ["sizes", "source_set", "placeholder"] 16 | 17 | 18 | def _grid(*, field, _columns=12, **breakpoint_sizes): 19 | for key in breakpoint_sizes.keys() - field.breakpoints: 20 | raise KeyError( 21 | f"Invalid breakpoint: {key}. Choices are: {', '.join(field.breakpoints.keys())}" 22 | ) 23 | prev_size = _columns 24 | for key, value in field.breakpoints.items(): 25 | prev_size = breakpoint_sizes.get(key, prev_size) 26 | yield key, prev_size / _columns 27 | 28 | 29 | def _media_query(*, field, container_width: int | None = None, **breakpoints: int): 30 | prev_ratio = None 31 | prev_width = 0 32 | for key, ratio in breakpoints.items(): 33 | width = field.breakpoints[key] 34 | if container_width and width >= container_width: 35 | yield f"(min-width: {prev_width}px) and (max-width: {container_width - 1}px) {math.floor(ratio * 100)}vw" 36 | break 37 | if prev_ratio and prev_ratio != ratio: 38 | yield f"(min-width: {prev_width}px) and (max-width: {width - 1}px) {math.floor(prev_ratio * 100)}vw" 39 | prev_width = width 40 | prev_ratio = ratio 41 | if prev_ratio: 42 | yield ( 43 | f"{math.floor(prev_ratio * container_width)}px" 44 | if container_width 45 | else f"{math.floor(prev_ratio * 100)}vw" 46 | ) 47 | else: 48 | warnings.warn( 49 | "Your container is smaller than all your breakpoints.", UserWarning 50 | ) 51 | yield f"{container_width}px" if container_width else "100vw" 52 | 53 | 54 | def sizes( 55 | *, field, cols=12, container_width: int | None = None, **breakpoints: int 56 | ) -> str: 57 | breakpoints = dict(_grid(field=field, _columns=cols, **breakpoints)) 58 | return ", ".join( 59 | _media_query(field=field, container_width=container_width, **breakpoints) 60 | ) 61 | 62 | 63 | def source_set( 64 | size: (int, int), *, ratio: str | Fraction | None, max_width: int, cols: int 65 | ) -> set: 66 | ratio = Fraction(ratio) if ratio else None 67 | img_width, img_height = size 68 | ratio = ratio or Fraction(img_width, img_height) 69 | settings = conf.get_settings() 70 | # calc all widths at 1X resolution 71 | widths = (max_width * (w + 1) / cols for w in range(cols)) 72 | # exclude widths above the max width 73 | widths = (w for w in widths if w <= max_width) 74 | # sizes for all screen resolutions 75 | widths = (w * res for w in widths for res in settings.PIXEL_DENSITIES) 76 | # exclude sizes above the original image width or height 77 | return {math.floor(w) for w in widths if w <= img_width and w / ratio <= img_height} 78 | 79 | 80 | @lru_cache 81 | def placeholder(width: int, height: int, alt): 82 | hue = random.randint(0, 360) # nosec 83 | img = Image.new("RGB", (width, height), color=f"hsl({hue}, 40%, 80%)") 84 | draw = ImageDraw.Draw(img) 85 | draw.line(((0, 0, width, height)), width=3, fill=f"hsl({hue}, 60%, 20%)") 86 | draw.line(((0, height, width, 0)), width=3, fill=f"hsl({hue}, 60%, 20%)") 87 | draw.rectangle( 88 | (width / 4, height / 4, width * 3 / 4, height * 3 / 4), 89 | fill=f"hsl({hue}, 40%, 80%)", 90 | ) 91 | 92 | fontsize = 32 93 | if sys.platform == "win32": 94 | font_name = r"C:\WINDOWS\Fonts\CALIBRI.TTF" 95 | elif sys.platform in ["linux", "linux2"]: 96 | font_name = "DejaVuSans-Bold" 97 | elif sys.platform == "darwin": 98 | font_name = "Helvetica" 99 | else: # pragma: no cover 100 | raise RuntimeError(f"Unsupported platform: {sys.platform}") 101 | font = ImageFont.truetype(font_name, fontsize) 102 | text = unquote(f"{alt}\n<{width}x{height}>") 103 | while font.getlength(text) < width / 2: 104 | # iterate until the text size is just larger than the criteria 105 | fontsize += 1 106 | font = ImageFont.truetype(font_name, fontsize) 107 | 108 | draw.text( 109 | (width / 2, height / 2), 110 | text, 111 | font=font, 112 | fill=f"hsl({hue}, 60%, 20%)", 113 | align="center", 114 | anchor="mm", 115 | ) 116 | return img 117 | 118 | 119 | def reconstruct(path: str, args: list, kwargs: dict): 120 | """Reconstruct a class instance from its deconstructed state.""" 121 | module_name, _, name = path.rpartition(".") 122 | module = __import__(module_name, fromlist=[name]) 123 | klass = getattr(module, name) 124 | _args = [] 125 | _kwargs = {} 126 | for arg in args: 127 | try: 128 | _args.append(reconstruct(*arg)) 129 | except (TypeError, ValueError, ImportError): 130 | _args.append(arg) 131 | for key, value in kwargs.items(): 132 | try: 133 | _kwargs[key] = reconstruct(*value) 134 | except (TypeError, ValueError, ImportError): 135 | _kwargs[key] = value 136 | return klass(*_args, **_kwargs) 137 | -------------------------------------------------------------------------------- /pictures/validators.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import BaseValidator 5 | from django.utils.translation import gettext_lazy as _ 6 | from PIL import Image 7 | 8 | 9 | class BaseSizeValidator(BaseValidator): 10 | """Base validator that validates the size of an image.""" 11 | 12 | def compare(self, x): 13 | return NotImplemented 14 | 15 | def __init__(self, width, height): 16 | self.limit_value = width or float("inf"), height or float("inf") 17 | 18 | def __call__(self, value): 19 | cleaned = self.clean(value) 20 | if self.compare(cleaned, self.limit_value): 21 | params = { 22 | "width": self.limit_value[0], 23 | "height": self.limit_value[1], 24 | } 25 | raise ValidationError(self.message, code=self.code, params=params) 26 | 27 | @staticmethod 28 | def clean(value): 29 | value.seek(0) 30 | stream = io.BytesIO(value.read()) 31 | size = Image.open(stream).size 32 | value.seek(0) 33 | return size 34 | 35 | 36 | class MaxSizeValidator(BaseSizeValidator): 37 | """ 38 | ImageField validator to validate the max width and height of an image. 39 | 40 | You may use None as an infinite boundary. 41 | """ 42 | 43 | def compare(self, img_size, max_size): 44 | return img_size[0] > max_size[0] or img_size[1] > max_size[1] 45 | 46 | message = _( 47 | "The image you uploaded is too large." 48 | " The required maximum resolution is:" 49 | " %(width)sx%(height)s px." 50 | ) 51 | code = "max_resolution" 52 | 53 | 54 | class MinSizeValidator(BaseSizeValidator): 55 | """ 56 | ImageField validator to validate the min width and height of an image. 57 | 58 | You may use None as an infinite boundary. 59 | """ 60 | 61 | def compare(self, img_size, min_size): 62 | return img_size[0] < min_size[0] or img_size[1] < min_size[1] 63 | 64 | message = _( 65 | "The image you uploaded is too small." 66 | " The required minimum resolution is:" 67 | " %(width)sx%(height)s px." 68 | ) 69 | -------------------------------------------------------------------------------- /pictures/views.py: -------------------------------------------------------------------------------- 1 | import math 2 | from fractions import Fraction 3 | 4 | from django.http import Http404, HttpResponse 5 | 6 | from . import conf, utils 7 | 8 | 9 | def placeholder(request, width, ratio, file_type, alt): 10 | try: 11 | ratio = Fraction(ratio.replace("x", "/")) 12 | except ValueError: 13 | raise Http404() 14 | settings = conf.get_settings() 15 | height = math.floor(width / ratio) 16 | if file_type.upper() not in settings.FILE_TYPES: 17 | raise Http404("File type not allowed") 18 | img = utils.placeholder(width, height, alt=alt) 19 | response = HttpResponse( 20 | content_type=f"image/{file_type.lower()}", 21 | headers={"Cache-Control": f"public, max-age={60 * 60 * 24 * 365}"}, # 1 year 22 | ) 23 | img.save(response, file_type.upper()) 24 | return response 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2", "flit_scm", "wheel"] 3 | build-backend = "flit_scm:buildapi" 4 | 5 | [project] 6 | name = "django-pictures" 7 | authors = [ 8 | { name = "Johannes Maron", email = "johannes@maron.family" }, 9 | ] 10 | readme = "README.md" 11 | license = { file = "LICENSE" } 12 | keywords = ["pillow", "Django", "image", "pictures", "WebP", "AVIF"] 13 | dynamic = ["version", "description"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Programming Language :: Python", 17 | "Environment :: Web Environment", 18 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: BSD License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python", 23 | "Topic :: Software Development", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3 :: Only", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Programming Language :: Python :: 3.13", 31 | "Framework :: Django", 32 | "Framework :: Django :: 4.2", 33 | "Framework :: Django :: 5.1", 34 | "Framework :: Django :: 5.2", 35 | ] 36 | requires-python = ">=3.9" 37 | dependencies = ["django>=4.2.0", "pillow>=9.0.0"] 38 | 39 | [project.optional-dependencies] 40 | test = [ 41 | "pytest", 42 | "pytest-cov", 43 | "pytest-django", 44 | "redis", 45 | ] 46 | lint = [ 47 | "bandit==1.8.3", 48 | "black==25.1.0", 49 | "flake8==7.2.0", 50 | "isort==6.0.1", 51 | "pydocstyle[toml]==6.3.0", 52 | ] 53 | dramatiq = [ 54 | "django-dramatiq", 55 | ] 56 | celery = [ 57 | "celery", 58 | ] 59 | django-rq = [ 60 | "django-rq", 61 | ] 62 | drf = [ 63 | "djangorestframework", 64 | ] 65 | cleanup = [ 66 | "django-cleanup", 67 | ] 68 | 69 | [project.urls] 70 | Project-URL = "https://github.com/codingjoe/django-pictures" 71 | Changelog = "https://github.com/codingjoe/django-pictures/releases" 72 | Source = "https://github.com/codingjoe/django-pictures" 73 | Documentation = "https://github.com/codingjoe/django-pictures#django-pictures" 74 | Issue-Tracker = "https://github.com/codingjoe/django-pictures/issues" 75 | 76 | [tool.flit.module] 77 | name = "pictures" 78 | 79 | [tool.setuptools_scm] 80 | write_to = "pictures/_version.py" 81 | 82 | [tool.pytest.ini_options] 83 | minversion = "6.0" 84 | addopts = "--cov --tb=short -rxs" 85 | testpaths = ["tests"] 86 | DJANGO_SETTINGS_MODULE = "tests.testapp.settings" 87 | 88 | [tool.coverage.run] 89 | source = ["pictures"] 90 | 91 | [tool.coverage.report] 92 | show_missing = true 93 | 94 | [tool.isort] 95 | atomic = true 96 | line_length = 88 97 | known_first_party = "pictures, tests" 98 | include_trailing_comma = true 99 | default_section = "THIRDPARTY" 100 | combine_as_imports = true 101 | skip = ["pictures/_version.py"] 102 | 103 | [tool.pydocstyle] 104 | add_ignore = "D1" 105 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | select = C,E,F,W,B,B950 4 | ignore = E203, E501, W503, E704, E731 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-pictures/fcc1eee502754c1d69f55cd7128bb8e8c2503a69/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import io 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | from django.core.files.uploadedfile import SimpleUploadedFile 6 | from PIL import Image 7 | 8 | from pictures import conf 9 | 10 | 11 | @pytest.fixture 12 | def image_upload_file(): 13 | img = Image.new("RGBA", (800, 800), (255, 55, 255, 1)) 14 | 15 | with io.BytesIO() as output: 16 | img.save(output, format="PNG") 17 | return SimpleUploadedFile("image.png", output.getvalue()) 18 | 19 | 20 | @pytest.fixture 21 | def tiny_image_upload_file(): 22 | img = Image.new("RGBA", (1, 1), (255, 55, 255, 1)) 23 | 24 | with io.BytesIO() as output: 25 | img.save(output, format="PNG") 26 | return SimpleUploadedFile("image.png", output.getvalue()) 27 | 28 | 29 | @pytest.fixture 30 | def large_image_upload_file(): 31 | img = Image.new("RGBA", (1000, 1000), (255, 55, 255, 1)) 32 | 33 | with io.BytesIO() as output: 34 | img.save(output, format="PNG") 35 | return SimpleUploadedFile("image.png", output.getvalue()) 36 | 37 | 38 | @pytest.fixture(autouse=True, scope="function") 39 | def media_root(settings, tmpdir_factory): 40 | settings.MEDIA_ROOT = tmpdir_factory.mktemp("media", numbered=True) 41 | 42 | 43 | @pytest.fixture(autouse=True) 44 | def instant_commit(monkeypatch): 45 | monkeypatch.setattr("django.db.transaction.on_commit", lambda f: f()) 46 | 47 | 48 | @pytest.fixture() 49 | def stub_worker(): 50 | try: 51 | import dramatiq 52 | except ImportError: 53 | try: 54 | from django_rq import get_worker 55 | except ImportError: 56 | yield Mock() 57 | else: 58 | 59 | class Meta: 60 | @staticmethod 61 | def join(): 62 | get_worker("pictures").work(burst=True) 63 | 64 | yield Meta 65 | 66 | else: 67 | broker = dramatiq.get_broker() 68 | broker.emit_after("process_boot") 69 | broker.flush_all() 70 | worker = dramatiq.Worker(broker, worker_timeout=100) 71 | worker.start() 72 | 73 | class Meta: 74 | @staticmethod 75 | def join(): 76 | broker.join(conf.get_settings().QUEUE_NAME, timeout=60000) 77 | worker.join() 78 | 79 | yield Meta 80 | worker.stop() 81 | -------------------------------------------------------------------------------- /tests/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingjoe/django-pictures/fcc1eee502754c1d69f55cd7128bb8e8c2503a69/tests/contrib/__init__.py -------------------------------------------------------------------------------- /tests/contrib/test_cleanup.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.files.storage import default_storage 3 | from django.db import router, transaction 4 | 5 | from tests.testapp.models import SimpleModel 6 | 7 | pytest.importorskip("django_cleanup") 8 | 9 | 10 | def get_using(instance): 11 | return router.db_for_write(instance.__class__, instance=instance) 12 | 13 | 14 | class TestCleanCase: 15 | @pytest.mark.django_db(transaction=True) 16 | def test_delete(self, stub_worker, image_upload_file): 17 | obj = SimpleModel(picture=image_upload_file) 18 | obj.save() 19 | stub_worker.join() 20 | 21 | name = obj.picture.name 22 | path = obj.picture.aspect_ratios["16/9"]["WEBP"][100].path 23 | assert default_storage.exists(name) 24 | assert path.exists() 25 | with transaction.atomic(get_using(obj)): 26 | obj.delete() 27 | assert not default_storage.exists(name) 28 | assert not path.exists() 29 | -------------------------------------------------------------------------------- /tests/contrib/test_rest_framework.py: -------------------------------------------------------------------------------- 1 | from fractions import Fraction 2 | 3 | import pytest 4 | from django.core.files.storage import default_storage 5 | 6 | from pictures.models import Picture 7 | from tests.testapp import models 8 | 9 | serializers = pytest.importorskip("rest_framework.serializers") 10 | rest_framework = pytest.importorskip("pictures.contrib.rest_framework") 11 | 12 | 13 | class ProfileSerializer(serializers.ModelSerializer): 14 | image = rest_framework.PictureField(source="picture") 15 | image_mobile = rest_framework.PictureField( 16 | source="picture", aspect_ratios=["3/2"], file_types=["WEBP"] 17 | ) 18 | 19 | class Meta: 20 | model = models.Profile 21 | fields = ["image", "image_mobile"] 22 | 23 | 24 | class ProfileSerializerWithInvalidData(serializers.ModelSerializer): 25 | image_invalid = rest_framework.PictureField( 26 | source="picture", aspect_ratios=["21/11"], file_types=["GIF"] 27 | ) 28 | 29 | class Meta: 30 | model = models.Profile 31 | fields = ["image_invalid"] 32 | 33 | 34 | class TestPicture(Picture): 35 | @property 36 | def url(self): 37 | return f"/media/{self.parent_name}" 38 | 39 | 40 | def test_default(settings): 41 | settings.PICTURES["USE_PLACEHOLDERS"] = False 42 | assert ( 43 | rest_framework.default( 44 | obj=TestPicture( 45 | parent_name="testapp/simplemodel/image.jpg", 46 | file_type="WEBP", 47 | aspect_ratio=Fraction("4/3"), 48 | storage=default_storage, 49 | width=800, 50 | ) 51 | ) 52 | == "/media/testapp/simplemodel/image.jpg" 53 | ) 54 | 55 | 56 | def test_default__type_error(): 57 | with pytest.raises(TypeError) as e: 58 | rest_framework.default("not a picture") 59 | assert str(e.value) == "Type 'str' not serializable" 60 | 61 | 62 | class TestPictureField: 63 | @pytest.mark.django_db 64 | def test_to_representation(self, image_upload_file, settings): 65 | settings.PICTURES["USE_PLACEHOLDERS"] = False 66 | 67 | profile = models.Profile.objects.create(picture=image_upload_file) 68 | serializer = ProfileSerializer(profile) 69 | 70 | assert serializer.data["image"] == { 71 | "url": "/media/testapp/profile/image.png", 72 | "width": 800, 73 | "height": 800, 74 | "ratios": { 75 | "null": { 76 | "sources": { 77 | "image/webp": { 78 | "800": "/media/testapp/profile/image/800w.webp", 79 | "100": "/media/testapp/profile/image/100w.webp", 80 | "200": "/media/testapp/profile/image/200w.webp", 81 | "300": "/media/testapp/profile/image/300w.webp", 82 | "400": "/media/testapp/profile/image/400w.webp", 83 | "500": "/media/testapp/profile/image/500w.webp", 84 | "600": "/media/testapp/profile/image/600w.webp", 85 | "700": "/media/testapp/profile/image/700w.webp", 86 | } 87 | }, 88 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 89 | }, 90 | "1/1": { 91 | "sources": { 92 | "image/webp": { 93 | "800": "/media/testapp/profile/image/1/800w.webp", 94 | "100": "/media/testapp/profile/image/1/100w.webp", 95 | "200": "/media/testapp/profile/image/1/200w.webp", 96 | "300": "/media/testapp/profile/image/1/300w.webp", 97 | "400": "/media/testapp/profile/image/1/400w.webp", 98 | "500": "/media/testapp/profile/image/1/500w.webp", 99 | "600": "/media/testapp/profile/image/1/600w.webp", 100 | "700": "/media/testapp/profile/image/1/700w.webp", 101 | } 102 | }, 103 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 104 | }, 105 | "3/2": { 106 | "sources": { 107 | "image/webp": { 108 | "800": "/media/testapp/profile/image/3_2/800w.webp", 109 | "100": "/media/testapp/profile/image/3_2/100w.webp", 110 | "200": "/media/testapp/profile/image/3_2/200w.webp", 111 | "300": "/media/testapp/profile/image/3_2/300w.webp", 112 | "400": "/media/testapp/profile/image/3_2/400w.webp", 113 | "500": "/media/testapp/profile/image/3_2/500w.webp", 114 | "600": "/media/testapp/profile/image/3_2/600w.webp", 115 | "700": "/media/testapp/profile/image/3_2/700w.webp", 116 | } 117 | }, 118 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 119 | }, 120 | "16/9": { 121 | "sources": { 122 | "image/webp": { 123 | "800": "/media/testapp/profile/image/16_9/800w.webp", 124 | "100": "/media/testapp/profile/image/16_9/100w.webp", 125 | "200": "/media/testapp/profile/image/16_9/200w.webp", 126 | "300": "/media/testapp/profile/image/16_9/300w.webp", 127 | "400": "/media/testapp/profile/image/16_9/400w.webp", 128 | "500": "/media/testapp/profile/image/16_9/500w.webp", 129 | "600": "/media/testapp/profile/image/16_9/600w.webp", 130 | "700": "/media/testapp/profile/image/16_9/700w.webp", 131 | } 132 | }, 133 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 134 | }, 135 | }, 136 | } 137 | 138 | @pytest.mark.django_db 139 | def test_to_representation__with_aspect_ratios( 140 | self, rf, image_upload_file, settings 141 | ): 142 | settings.PICTURES["USE_PLACEHOLDERS"] = False 143 | 144 | profile = models.Profile.objects.create(picture=image_upload_file) 145 | request = rf.get("/") 146 | request.GET._mutable = True 147 | request.GET["image_ratio"] = "1/1" 148 | request.GET["image_l"] = "3" 149 | request.GET["image_m"] = "4" 150 | serializer = ProfileSerializer(profile, context={"request": request}) 151 | 152 | assert serializer.data["image"] == { 153 | "url": "/media/testapp/profile/image.png", 154 | "width": 800, 155 | "height": 800, 156 | "ratios": { 157 | "1/1": { 158 | "sources": { 159 | "image/webp": { 160 | "800": "/media/testapp/profile/image/1/800w.webp", 161 | "100": "/media/testapp/profile/image/1/100w.webp", 162 | "200": "/media/testapp/profile/image/1/200w.webp", 163 | "300": "/media/testapp/profile/image/1/300w.webp", 164 | "400": "/media/testapp/profile/image/1/400w.webp", 165 | "500": "/media/testapp/profile/image/1/500w.webp", 166 | "600": "/media/testapp/profile/image/1/600w.webp", 167 | "700": "/media/testapp/profile/image/1/700w.webp", 168 | } 169 | }, 170 | "media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 25vw, 400px", 171 | } 172 | }, 173 | } 174 | 175 | @pytest.mark.django_db 176 | def test_to_representation__raise_value_error( 177 | self, rf, image_upload_file, settings 178 | ): 179 | settings.PICTURES["USE_PLACEHOLDERS"] = False 180 | 181 | profile = models.Profile.objects.create(picture=image_upload_file) 182 | request = rf.get("/") 183 | request.GET._mutable = True 184 | request.GET["image_ratio"] = "21/11" 185 | request.GET["image_l"] = "3" 186 | request.GET["image_m"] = "4" 187 | serializer = ProfileSerializer(profile, context={"request": request}) 188 | 189 | with pytest.raises(ValueError) as e: 190 | serializer.data["image"] 191 | 192 | assert str(e.value) == "Invalid ratios: 21/11. Choices are: 1/1, 3/2, 16/9" 193 | 194 | @pytest.mark.django_db 195 | def test_to_representation__blank(self, rf, image_upload_file, settings): 196 | settings.PICTURES["USE_PLACEHOLDERS"] = False 197 | 198 | profile = models.Profile.objects.create() 199 | request = rf.get("/") 200 | request.GET._mutable = True 201 | request.GET["image_ratio"] = "21/11" 202 | request.GET["image_l"] = "3" 203 | request.GET["image_m"] = "4" 204 | serializer = ProfileSerializer(profile, context={"request": request}) 205 | 206 | assert serializer.data["image"] is None 207 | 208 | @pytest.mark.django_db 209 | def test_to_representation__no_get_params(self, rf, image_upload_file, settings): 210 | settings.PICTURES["USE_PLACEHOLDERS"] = False 211 | 212 | profile = models.Profile.objects.create(picture=image_upload_file) 213 | request = rf.get("/") 214 | request.GET._mutable = True 215 | request.GET["foo"] = "bar" 216 | serializer = ProfileSerializer(profile, context={"request": request}) 217 | assert serializer.data["image_mobile"] == { 218 | "url": "/media/testapp/profile/image.png", 219 | "width": 800, 220 | "height": 800, 221 | "ratios": { 222 | "3/2": { 223 | "sources": { 224 | "image/webp": { 225 | "800": "/media/testapp/profile/image/3_2/800w.webp", 226 | "100": "/media/testapp/profile/image/3_2/100w.webp", 227 | "200": "/media/testapp/profile/image/3_2/200w.webp", 228 | "300": "/media/testapp/profile/image/3_2/300w.webp", 229 | "400": "/media/testapp/profile/image/3_2/400w.webp", 230 | "500": "/media/testapp/profile/image/3_2/500w.webp", 231 | "600": "/media/testapp/profile/image/3_2/600w.webp", 232 | "700": "/media/testapp/profile/image/3_2/700w.webp", 233 | } 234 | }, 235 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 236 | } 237 | }, 238 | } 239 | 240 | @pytest.mark.django_db 241 | def test_to_representation__multiple_ratios(self, rf, image_upload_file, settings): 242 | settings.PICTURES["USE_PLACEHOLDERS"] = False 243 | 244 | profile = models.Profile.objects.create(picture=image_upload_file) 245 | request = rf.get("/") 246 | request.GET._mutable = True 247 | request.GET.setlist("image_ratio", ["3/2", "16/9"]) 248 | serializer = ProfileSerializer(profile, context={"request": request}) 249 | print(serializer.data["image"]) 250 | assert serializer.data["image"] == { 251 | "url": "/media/testapp/profile/image.png", 252 | "width": 800, 253 | "height": 800, 254 | "ratios": { 255 | "3/2": { 256 | "sources": { 257 | "image/webp": { 258 | "800": "/media/testapp/profile/image/3_2/800w.webp", 259 | "100": "/media/testapp/profile/image/3_2/100w.webp", 260 | "200": "/media/testapp/profile/image/3_2/200w.webp", 261 | "300": "/media/testapp/profile/image/3_2/300w.webp", 262 | "400": "/media/testapp/profile/image/3_2/400w.webp", 263 | "500": "/media/testapp/profile/image/3_2/500w.webp", 264 | "600": "/media/testapp/profile/image/3_2/600w.webp", 265 | "700": "/media/testapp/profile/image/3_2/700w.webp", 266 | } 267 | }, 268 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 269 | }, 270 | "16/9": { 271 | "sources": { 272 | "image/webp": { 273 | "800": "/media/testapp/profile/image/16_9/800w.webp", 274 | "100": "/media/testapp/profile/image/16_9/100w.webp", 275 | "200": "/media/testapp/profile/image/16_9/200w.webp", 276 | "300": "/media/testapp/profile/image/16_9/300w.webp", 277 | "400": "/media/testapp/profile/image/16_9/400w.webp", 278 | "500": "/media/testapp/profile/image/16_9/500w.webp", 279 | "600": "/media/testapp/profile/image/16_9/600w.webp", 280 | "700": "/media/testapp/profile/image/16_9/700w.webp", 281 | } 282 | }, 283 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 284 | }, 285 | }, 286 | } 287 | 288 | @pytest.mark.django_db 289 | def test_to_representation__with_container(self, rf, image_upload_file, settings): 290 | settings.PICTURES["USE_PLACEHOLDERS"] = False 291 | 292 | profile = models.Profile.objects.create(picture=image_upload_file) 293 | request = rf.get("/") 294 | request.GET._mutable = True 295 | request.GET["image_ratio"] = "16/9" 296 | request.GET["image_container"] = "1200" 297 | serializer = ProfileSerializer(profile, context={"request": request}) 298 | assert serializer.data["image"] == { 299 | "url": "/media/testapp/profile/image.png", 300 | "width": 800, 301 | "height": 800, 302 | "ratios": { 303 | "16/9": { 304 | "sources": { 305 | "image/webp": { 306 | "800": "/media/testapp/profile/image/16_9/800w.webp", 307 | "100": "/media/testapp/profile/image/16_9/100w.webp", 308 | "200": "/media/testapp/profile/image/16_9/200w.webp", 309 | "300": "/media/testapp/profile/image/16_9/300w.webp", 310 | "400": "/media/testapp/profile/image/16_9/400w.webp", 311 | "500": "/media/testapp/profile/image/16_9/500w.webp", 312 | "600": "/media/testapp/profile/image/16_9/600w.webp", 313 | "700": "/media/testapp/profile/image/16_9/700w.webp", 314 | } 315 | }, 316 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 317 | } 318 | }, 319 | } 320 | 321 | @pytest.mark.django_db 322 | def test_to_representation__without_container( 323 | self, rf, image_upload_file, settings 324 | ): 325 | settings.PICTURES["USE_PLACEHOLDERS"] = False 326 | 327 | profile = models.Profile.objects.create(picture=image_upload_file) 328 | request = rf.get("/") 329 | request.GET._mutable = True 330 | request.GET["image_ratio"] = "16/9" 331 | serializer = ProfileSerializer(profile, context={"request": request}) 332 | assert serializer.data["image"] == { 333 | "url": "/media/testapp/profile/image.png", 334 | "width": 800, 335 | "height": 800, 336 | "ratios": { 337 | "16/9": { 338 | "sources": { 339 | "image/webp": { 340 | "800": "/media/testapp/profile/image/16_9/800w.webp", 341 | "100": "/media/testapp/profile/image/16_9/100w.webp", 342 | "200": "/media/testapp/profile/image/16_9/200w.webp", 343 | "300": "/media/testapp/profile/image/16_9/300w.webp", 344 | "400": "/media/testapp/profile/image/16_9/400w.webp", 345 | "500": "/media/testapp/profile/image/16_9/500w.webp", 346 | "600": "/media/testapp/profile/image/16_9/600w.webp", 347 | "700": "/media/testapp/profile/image/16_9/700w.webp", 348 | } 349 | }, 350 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 351 | } 352 | }, 353 | } 354 | 355 | @pytest.mark.django_db 356 | def test_to_representation__with_false_str_container( 357 | self, rf, image_upload_file, settings 358 | ): 359 | settings.PICTURES["USE_PLACEHOLDERS"] = False 360 | 361 | profile = models.Profile.objects.create(picture=image_upload_file) 362 | request = rf.get("/") 363 | request.GET._mutable = True 364 | request.GET["image_ratio"] = "16/9" 365 | request.GET["image_container"] = "not_a_number" 366 | serializer = ProfileSerializer(profile, context={"request": request}) 367 | with pytest.raises(ValueError) as e: 368 | serializer.data["image"] 369 | assert str(e.value) == "Container width is not a number: not_a_number" 370 | 371 | @pytest.mark.django_db 372 | def test_to_representation__with_prefiltered_aspect_ratio_and_source( 373 | self, image_upload_file, settings 374 | ): 375 | settings.PICTURES["USE_PLACEHOLDERS"] = False 376 | 377 | profile = models.Profile.objects.create(picture=image_upload_file) 378 | serializer = ProfileSerializer(profile) 379 | 380 | assert serializer.data["image_mobile"] == { 381 | "url": "/media/testapp/profile/image.png", 382 | "width": 800, 383 | "height": 800, 384 | "ratios": { 385 | "3/2": { 386 | "sources": { 387 | "image/webp": { 388 | "800": "/media/testapp/profile/image/3_2/800w.webp", 389 | "100": "/media/testapp/profile/image/3_2/100w.webp", 390 | "200": "/media/testapp/profile/image/3_2/200w.webp", 391 | "300": "/media/testapp/profile/image/3_2/300w.webp", 392 | "400": "/media/testapp/profile/image/3_2/400w.webp", 393 | "500": "/media/testapp/profile/image/3_2/500w.webp", 394 | "600": "/media/testapp/profile/image/3_2/600w.webp", 395 | "700": "/media/testapp/profile/image/3_2/700w.webp", 396 | } 397 | }, 398 | "media": "(min-width: 0px) and (max-width: 1199px) 100vw, 1200px", 399 | } 400 | }, 401 | } 402 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from django.urls import NoReverseMatch 4 | 5 | from pictures import checks 6 | 7 | 8 | def test_placeholder_url_check(settings, monkeypatch): 9 | """Test that the placeholder URL check works.""" 10 | 11 | settings.PICTURES["USE_PLACEHOLDERS"] = True 12 | assert not checks.placeholder_url_check({}) 13 | 14 | reverse = Mock(side_effect=NoReverseMatch) 15 | monkeypatch.setattr(checks, "reverse", reverse) 16 | 17 | assert checks.placeholder_url_check({}) 18 | 19 | settings.PICTURES["USE_PLACEHOLDERS"] = False 20 | assert not checks.placeholder_url_check({}) 21 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | from django.db import models 6 | from django.db.models.fields.files import ImageFieldFile 7 | 8 | from pictures import migrations 9 | from pictures.models import PictureField 10 | from tests.testapp.models import Profile 11 | 12 | try: 13 | import dramatiq 14 | except ImportError: 15 | dramatiq = None 16 | 17 | try: 18 | import celery 19 | except ImportError: 20 | celery = None 21 | 22 | try: 23 | import django_rq 24 | except ImportError: 25 | django_rq = None 26 | 27 | skip_dramatiq = pytest.mark.skipif( 28 | not all(x is None for x in [dramatiq, celery, django_rq]), 29 | reason="dramatiq, celery and django-rq are installed", 30 | ) 31 | 32 | 33 | @skip_dramatiq 34 | class TestAlterPictureField: 35 | def test_alter_picture_field__image_to_image(self, request, monkeypatch): 36 | class FromModel(models.Model): 37 | picture = models.ImageField() 38 | 39 | class Meta: 40 | app_label = request.node.name 41 | 42 | class ToModel(models.Model): 43 | picture = models.ImageField() 44 | 45 | class Meta: 46 | app_label = request.node.name 47 | 48 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 49 | monkeypatch.setattr(migration, "update_pictures", Mock()) 50 | monkeypatch.setattr(migration, "from_picture_field", Mock()) 51 | monkeypatch.setattr(migration, "to_picture_field", Mock()) 52 | migration.alter_picture_field(FromModel, ToModel) 53 | 54 | assert not migration.update_pictures.called 55 | assert not migration.from_picture_field.called 56 | assert not migration.to_picture_field.called 57 | 58 | def test_alter_picture_field__image_to_picture(self, request, monkeypatch): 59 | class FromModel(models.Model): 60 | picture = models.ImageField() 61 | 62 | class Meta: 63 | app_label = request.node.name 64 | 65 | class ToModel(models.Model): 66 | picture = PictureField() 67 | 68 | class Meta: 69 | app_label = request.node.name 70 | 71 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 72 | monkeypatch.setattr(migration, "to_picture_field", Mock()) 73 | migration.alter_picture_field(FromModel, ToModel) 74 | 75 | migration.to_picture_field.assert_called_once_with(FromModel, ToModel) 76 | 77 | def test_alter_picture_field__picture_to_image(self, request, monkeypatch): 78 | class FromModel(models.Model): 79 | picture = PictureField() 80 | 81 | class Meta: 82 | app_label = request.node.name 83 | 84 | class ToModel(models.Model): 85 | picture = models.ImageField() 86 | 87 | class Meta: 88 | app_label = request.node.name 89 | 90 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 91 | monkeypatch.setattr(migration, "from_picture_field", Mock()) 92 | migration.alter_picture_field(FromModel, ToModel) 93 | 94 | migration.from_picture_field.assert_called_once_with(FromModel) 95 | 96 | def test_alter_picture_field__picture_to_picture(self, request, monkeypatch): 97 | class FromModel(models.Model): 98 | picture = PictureField() 99 | 100 | class Meta: 101 | app_label = request.node.name 102 | 103 | class ToModel(models.Model): 104 | picture = PictureField() 105 | 106 | class Meta: 107 | app_label = request.node.name 108 | 109 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 110 | monkeypatch.setattr(migration, "update_pictures", Mock()) 111 | monkeypatch.setattr(migration, "from_picture_field", Mock()) 112 | monkeypatch.setattr(migration, "to_picture_field", Mock()) 113 | migration.alter_picture_field(FromModel, ToModel) 114 | from_field = FromModel._meta.get_field("picture") 115 | migration.update_pictures.assert_called_once_with(from_field, ToModel) 116 | assert not migration.from_picture_field.called 117 | assert not migration.to_picture_field.called 118 | 119 | @pytest.mark.django_db 120 | def test_update_pictures(self, request, stub_worker, image_upload_file): 121 | class ToModel(models.Model): 122 | name = models.CharField(max_length=100) 123 | picture = PictureField( 124 | upload_to="testapp/profile/", aspect_ratios=[None, "21/9"] 125 | ) 126 | 127 | class Meta: 128 | app_label = request.node.name 129 | db_table = "testapp_profile" 130 | 131 | luke = Profile.objects.create(name="Luke", picture=image_upload_file) 132 | stub_worker.join() 133 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 134 | from_field = Profile._meta.get_field("picture") 135 | 136 | path = luke.picture.aspect_ratios["16/9"]["WEBP"][100].path 137 | assert path.exists() 138 | 139 | migration.update_pictures(from_field, ToModel) 140 | stub_worker.join() 141 | 142 | assert not path.exists() 143 | luke.refresh_from_db() 144 | path = ( 145 | ToModel.objects.get(pk=luke.pk) 146 | .picture.aspect_ratios["21/9"]["WEBP"][100] 147 | .path 148 | ) 149 | assert path.exists() 150 | 151 | @pytest.mark.django_db 152 | def test_update_pictures__without_picture(self, request, stub_worker): 153 | class ToModel(models.Model): 154 | name = models.CharField(max_length=100) 155 | picture = PictureField( 156 | upload_to="testapp/profile/", aspect_ratios=[None, "21/9"], blank=True 157 | ) 158 | 159 | class Meta: 160 | app_label = request.node.name 161 | db_table = "testapp_profile" 162 | 163 | luke = Profile.objects.create(name="Luke") 164 | stub_worker.join() 165 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 166 | from_field = Profile._meta.get_field("picture") 167 | 168 | migration.update_pictures(from_field, ToModel) 169 | stub_worker.join() 170 | luke.refresh_from_db() 171 | 172 | assert not luke.picture 173 | 174 | @pytest.mark.django_db 175 | def test_from_picture_field(self, stub_worker, image_upload_file): 176 | luke = Profile.objects.create(name="Luke", picture=image_upload_file) 177 | stub_worker.join() 178 | path = luke.picture.aspect_ratios["16/9"]["WEBP"][100].path 179 | assert path.exists() 180 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 181 | migration.from_picture_field(Profile) 182 | stub_worker.join() 183 | assert not path.exists() 184 | 185 | @pytest.mark.django_db 186 | def test_to_picture_field(self, request, stub_worker, image_upload_file): 187 | class FromModel(models.Model): 188 | picture = models.ImageField() 189 | 190 | class Meta: 191 | app_label = request.node.name 192 | db_table = "testapp_profile" 193 | 194 | class ToModel(models.Model): 195 | name = models.CharField(max_length=100) 196 | picture = models.ImageField(upload_to="testapp/profile/") 197 | 198 | class Meta: 199 | app_label = request.node.name 200 | db_table = "testapp_profile" 201 | 202 | luke = ToModel.objects.create(name="Luke", picture=image_upload_file) 203 | stub_worker.join() 204 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 205 | migration.to_picture_field(FromModel, Profile) 206 | stub_worker.join() 207 | luke.refresh_from_db() 208 | path = ( 209 | Profile.objects.get(pk=luke.pk) 210 | .picture.aspect_ratios["16/9"]["WEBP"][100] 211 | .path 212 | ) 213 | assert path.exists() 214 | 215 | @pytest.mark.django_db 216 | def test_to_picture_field_blank(self, request, stub_worker): 217 | class FromModel(models.Model): 218 | picture = models.ImageField(blank=True) 219 | 220 | class Meta: 221 | app_label = request.node.name 222 | db_table = "testapp_profile" 223 | 224 | class ToModel(models.Model): 225 | name = models.CharField(max_length=100) 226 | picture = models.ImageField(upload_to="testapp/profile/", blank=True) 227 | 228 | class Meta: 229 | app_label = request.node.name 230 | db_table = "testapp_profile" 231 | 232 | ToModel.objects.create(name="Luke") 233 | stub_worker.join() 234 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 235 | migration.to_picture_field(FromModel, Profile) 236 | 237 | @pytest.mark.django_db 238 | def test_to_picture_field__from_stdimage( 239 | self, request, stub_worker, image_upload_file 240 | ): 241 | class StdImageFieldFile(ImageFieldFile): 242 | delete_variations = Mock() 243 | 244 | class StdImageField(models.ImageField): 245 | attr_class = StdImageFieldFile 246 | 247 | class FromModel(models.Model): 248 | picture = StdImageField() 249 | 250 | class Meta: 251 | app_label = request.node.name 252 | db_table = "testapp_profile" 253 | 254 | class ToModel(models.Model): 255 | name = models.CharField(max_length=100) 256 | picture = models.ImageField(upload_to="testapp/profile/") 257 | 258 | class Meta: 259 | app_label = request.node.name 260 | db_table = "testapp_profile" 261 | 262 | luke = ToModel.objects.create(name="Luke", picture=image_upload_file) 263 | stub_worker.join() 264 | migration = migrations.AlterPictureField("profile", "picture", PictureField()) 265 | migration.to_picture_field(FromModel, Profile) 266 | stub_worker.join() 267 | luke.refresh_from_db() 268 | path = ( 269 | Profile.objects.get(pk=luke.pk) 270 | .picture.aspect_ratios["16/9"]["WEBP"][100] 271 | .path 272 | ) 273 | assert path.exists() 274 | assert StdImageFieldFile.delete_variations.called 275 | 276 | @pytest.mark.django_db(transaction=True) 277 | def test_database_backwards_forwards(self): 278 | call_command("migrate", "testapp", "0001") 279 | call_command("migrate", "testapp", "0002") 280 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import copy 3 | import io 4 | from fractions import Fraction 5 | from pathlib import Path 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from django.core.files.storage import default_storage 10 | from django.core.files.uploadedfile import SimpleUploadedFile 11 | from PIL import Image, ImageDraw 12 | 13 | from pictures.models import PictureField, PillowPicture 14 | from tests.testapp.models import JPEGModel, Profile, SimpleModel 15 | 16 | 17 | @contextlib.contextmanager 18 | def override_field_aspect_ratios(field, aspect_ratios): 19 | old_ratios = copy.deepcopy(field.aspect_ratios) 20 | field.aspect_ratios = aspect_ratios 21 | try: 22 | yield 23 | finally: 24 | field.aspect_ratios = old_ratios 25 | 26 | 27 | class TestPillowPicture: 28 | picture_with_ratio = PillowPicture( 29 | parent_name="testapp/simplemodel/image.png", 30 | file_type="WEBP", 31 | aspect_ratio=Fraction("4/3"), 32 | storage=default_storage, 33 | width=800, 34 | ) 35 | 36 | picture_without_ratio = PillowPicture( 37 | parent_name="testapp/simplemodel/image.png", 38 | file_type="WEBP", 39 | aspect_ratio=None, 40 | storage=default_storage, 41 | width=800, 42 | ) 43 | 44 | def test_hash(self): 45 | assert hash(self.picture_with_ratio) != hash(self.picture_without_ratio) 46 | assert hash(self.picture_with_ratio) == hash(self.picture_with_ratio) 47 | 48 | def test_eq(self): 49 | assert self.picture_with_ratio != self.picture_without_ratio 50 | assert self.picture_with_ratio == self.picture_with_ratio 51 | assert self.picture_with_ratio != "not a picture" 52 | 53 | def test_url(self, settings): 54 | settings.PICTURES["USE_PLACEHOLDERS"] = False 55 | assert ( 56 | self.picture_with_ratio.url 57 | == "/media/testapp/simplemodel/image/4_3/800w.webp" 58 | ) 59 | 60 | def test_url__placeholder(self, settings): 61 | settings.PICTURES["USE_PLACEHOLDERS"] = True 62 | assert self.picture_with_ratio.url == "/_pictures/image/4x3/800w.WEBP" 63 | 64 | def test_height(self): 65 | assert self.picture_with_ratio.height == 600 66 | assert not self.picture_without_ratio.height 67 | 68 | def test_name(self): 69 | assert Path(self.picture_without_ratio.name) == Path( 70 | "testapp/simplemodel/image/800w.webp" 71 | ) 72 | assert Path(self.picture_with_ratio.name) == Path( 73 | "testapp/simplemodel/image/4_3/800w.webp" 74 | ) 75 | 76 | def test_path(self): 77 | assert self.picture_with_ratio.path.is_absolute() 78 | 79 | def test_save(self): 80 | assert not self.picture_with_ratio.path.exists() 81 | self.picture_with_ratio.save(Image.new("RGB", (800, 800), (255, 55, 255))) 82 | assert self.picture_with_ratio.path.exists() 83 | 84 | def test_delete(self): 85 | self.picture_with_ratio.save(Image.new("RGB", (800, 800), (255, 55, 255))) 86 | assert self.picture_with_ratio.path.exists() 87 | self.picture_with_ratio.delete() 88 | assert not self.picture_with_ratio.path.exists() 89 | 90 | def test_process__copy(self): 91 | """Do not mutate input image.""" 92 | image = Image.new("RGB", (800, 800), (255, 55, 255)) 93 | assert PillowPicture( 94 | parent_name="testapp/simplemodel/image.png", 95 | file_type="WEBP", 96 | aspect_ratio=None, 97 | storage=default_storage, 98 | width=100, 99 | ).process(image).size == (100, 100) 100 | 101 | assert image.size == (800, 800), "Image was mutated." 102 | 103 | assert PillowPicture( 104 | parent_name="testapp/simplemodel/image.png", 105 | file_type="WEBP", 106 | aspect_ratio="4/3", 107 | storage=default_storage, 108 | width=400, 109 | ).process(image).size == (400, 300) 110 | 111 | assert image.size == (800, 800), "Image was mutated." 112 | 113 | 114 | class TestPictureFieldFile: 115 | 116 | @pytest.mark.django_db 117 | def test_symmetric_difference(self, image_upload_file): 118 | obj = SimpleModel.objects.create(picture=image_upload_file) 119 | assert obj.picture ^ obj.picture == (set(), set()) 120 | obj2 = Profile.objects.create(picture=image_upload_file) 121 | with pytest.raises(TypeError): 122 | obj.picture ^ "not a picture" 123 | assert obj.picture ^ obj2.picture == ( 124 | { 125 | PillowPicture( 126 | parent_name="testapp/simplemodel/image.png", 127 | file_type="WEBP", 128 | aspect_ratio=Fraction(3, 2), 129 | storage=default_storage, 130 | width=300, 131 | ), 132 | PillowPicture( 133 | parent_name="testapp/simplemodel/image.png", 134 | file_type="WEBP", 135 | aspect_ratio=None, 136 | storage=default_storage, 137 | width=600, 138 | ), 139 | PillowPicture( 140 | parent_name="testapp/simplemodel/image.png", 141 | file_type="WEBP", 142 | aspect_ratio=Fraction(3, 2), 143 | storage=default_storage, 144 | width=500, 145 | ), 146 | PillowPicture( 147 | parent_name="testapp/simplemodel/image.png", 148 | file_type="WEBP", 149 | aspect_ratio=Fraction(16, 9), 150 | storage=default_storage, 151 | width=500, 152 | ), 153 | PillowPicture( 154 | parent_name="testapp/simplemodel/image.png", 155 | file_type="WEBP", 156 | aspect_ratio=None, 157 | storage=default_storage, 158 | width=300, 159 | ), 160 | PillowPicture( 161 | parent_name="testapp/simplemodel/image.png", 162 | file_type="WEBP", 163 | aspect_ratio=Fraction(3, 2), 164 | storage=default_storage, 165 | width=700, 166 | ), 167 | PillowPicture( 168 | parent_name="testapp/simplemodel/image.png", 169 | file_type="WEBP", 170 | aspect_ratio=Fraction(3, 2), 171 | storage=default_storage, 172 | width=800, 173 | ), 174 | PillowPicture( 175 | parent_name="testapp/simplemodel/image.png", 176 | file_type="WEBP", 177 | aspect_ratio=Fraction(16, 9), 178 | storage=default_storage, 179 | width=200, 180 | ), 181 | PillowPicture( 182 | parent_name="testapp/simplemodel/image.png", 183 | file_type="WEBP", 184 | aspect_ratio=Fraction(16, 9), 185 | storage=default_storage, 186 | width=300, 187 | ), 188 | PillowPicture( 189 | parent_name="testapp/simplemodel/image.png", 190 | file_type="WEBP", 191 | aspect_ratio=Fraction(16, 9), 192 | storage=default_storage, 193 | width=700, 194 | ), 195 | PillowPicture( 196 | parent_name="testapp/simplemodel/image.png", 197 | file_type="WEBP", 198 | aspect_ratio=None, 199 | storage=default_storage, 200 | width=100, 201 | ), 202 | PillowPicture( 203 | parent_name="testapp/simplemodel/image.png", 204 | file_type="WEBP", 205 | aspect_ratio=Fraction(3, 2), 206 | storage=default_storage, 207 | width=400, 208 | ), 209 | PillowPicture( 210 | parent_name="testapp/simplemodel/image.png", 211 | file_type="WEBP", 212 | aspect_ratio=None, 213 | storage=default_storage, 214 | width=800, 215 | ), 216 | PillowPicture( 217 | parent_name="testapp/simplemodel/image.png", 218 | file_type="WEBP", 219 | aspect_ratio=Fraction(3, 2), 220 | storage=default_storage, 221 | width=100, 222 | ), 223 | PillowPicture( 224 | parent_name="testapp/simplemodel/image.png", 225 | file_type="WEBP", 226 | aspect_ratio=None, 227 | storage=default_storage, 228 | width=700, 229 | ), 230 | PillowPicture( 231 | parent_name="testapp/simplemodel/image.png", 232 | file_type="WEBP", 233 | aspect_ratio=None, 234 | storage=default_storage, 235 | width=200, 236 | ), 237 | PillowPicture( 238 | parent_name="testapp/simplemodel/image.png", 239 | file_type="WEBP", 240 | aspect_ratio=Fraction(3, 2), 241 | storage=default_storage, 242 | width=600, 243 | ), 244 | PillowPicture( 245 | parent_name="testapp/simplemodel/image.png", 246 | file_type="WEBP", 247 | aspect_ratio=None, 248 | storage=default_storage, 249 | width=500, 250 | ), 251 | PillowPicture( 252 | parent_name="testapp/simplemodel/image.png", 253 | file_type="WEBP", 254 | aspect_ratio=Fraction(16, 9), 255 | storage=default_storage, 256 | width=800, 257 | ), 258 | PillowPicture( 259 | parent_name="testapp/simplemodel/image.png", 260 | file_type="WEBP", 261 | aspect_ratio=Fraction(3, 2), 262 | storage=default_storage, 263 | width=200, 264 | ), 265 | PillowPicture( 266 | parent_name="testapp/simplemodel/image.png", 267 | file_type="WEBP", 268 | aspect_ratio=Fraction(16, 9), 269 | storage=default_storage, 270 | width=100, 271 | ), 272 | PillowPicture( 273 | parent_name="testapp/simplemodel/image.png", 274 | file_type="WEBP", 275 | aspect_ratio=Fraction(16, 9), 276 | storage=default_storage, 277 | width=400, 278 | ), 279 | PillowPicture( 280 | parent_name="testapp/simplemodel/image.png", 281 | file_type="WEBP", 282 | aspect_ratio=Fraction(16, 9), 283 | storage=default_storage, 284 | width=600, 285 | ), 286 | PillowPicture( 287 | parent_name="testapp/simplemodel/image.png", 288 | file_type="WEBP", 289 | aspect_ratio=None, 290 | storage=default_storage, 291 | width=400, 292 | ), 293 | }, 294 | { 295 | PillowPicture( 296 | parent_name="testapp/profile/image.png", 297 | file_type="WEBP", 298 | aspect_ratio=Fraction(1, 1), 299 | storage=default_storage, 300 | width=600, 301 | ), 302 | PillowPicture( 303 | parent_name="testapp/profile/image.png", 304 | file_type="WEBP", 305 | aspect_ratio=Fraction(3, 2), 306 | storage=default_storage, 307 | width=300, 308 | ), 309 | PillowPicture( 310 | parent_name="testapp/profile/image.png", 311 | file_type="WEBP", 312 | aspect_ratio=Fraction(1, 1), 313 | storage=default_storage, 314 | width=800, 315 | ), 316 | PillowPicture( 317 | parent_name="testapp/profile/image.png", 318 | file_type="WEBP", 319 | aspect_ratio=None, 320 | storage=default_storage, 321 | width=500, 322 | ), 323 | PillowPicture( 324 | parent_name="testapp/profile/image.png", 325 | file_type="WEBP", 326 | aspect_ratio=None, 327 | storage=default_storage, 328 | width=700, 329 | ), 330 | PillowPicture( 331 | parent_name="testapp/profile/image.png", 332 | file_type="WEBP", 333 | aspect_ratio=None, 334 | storage=default_storage, 335 | width=100, 336 | ), 337 | PillowPicture( 338 | parent_name="testapp/profile/image.png", 339 | file_type="WEBP", 340 | aspect_ratio=Fraction(16, 9), 341 | storage=default_storage, 342 | width=500, 343 | ), 344 | PillowPicture( 345 | parent_name="testapp/profile/image.png", 346 | file_type="WEBP", 347 | aspect_ratio=Fraction(16, 9), 348 | storage=default_storage, 349 | width=700, 350 | ), 351 | PillowPicture( 352 | parent_name="testapp/profile/image.png", 353 | file_type="WEBP", 354 | aspect_ratio=None, 355 | storage=default_storage, 356 | width=800, 357 | ), 358 | PillowPicture( 359 | parent_name="testapp/profile/image.png", 360 | file_type="WEBP", 361 | aspect_ratio=Fraction(1, 1), 362 | storage=default_storage, 363 | width=200, 364 | ), 365 | PillowPicture( 366 | parent_name="testapp/profile/image.png", 367 | file_type="WEBP", 368 | aspect_ratio=Fraction(16, 9), 369 | storage=default_storage, 370 | width=100, 371 | ), 372 | PillowPicture( 373 | parent_name="testapp/profile/image.png", 374 | file_type="WEBP", 375 | aspect_ratio=None, 376 | storage=default_storage, 377 | width=600, 378 | ), 379 | PillowPicture( 380 | parent_name="testapp/profile/image.png", 381 | file_type="WEBP", 382 | aspect_ratio=Fraction(1, 1), 383 | storage=default_storage, 384 | width=100, 385 | ), 386 | PillowPicture( 387 | parent_name="testapp/profile/image.png", 388 | file_type="WEBP", 389 | aspect_ratio=Fraction(1, 1), 390 | storage=default_storage, 391 | width=700, 392 | ), 393 | PillowPicture( 394 | parent_name="testapp/profile/image.png", 395 | file_type="WEBP", 396 | aspect_ratio=Fraction(3, 2), 397 | storage=default_storage, 398 | width=800, 399 | ), 400 | PillowPicture( 401 | parent_name="testapp/profile/image.png", 402 | file_type="WEBP", 403 | aspect_ratio=None, 404 | storage=default_storage, 405 | width=200, 406 | ), 407 | PillowPicture( 408 | parent_name="testapp/profile/image.png", 409 | file_type="WEBP", 410 | aspect_ratio=Fraction(3, 2), 411 | storage=default_storage, 412 | width=500, 413 | ), 414 | PillowPicture( 415 | parent_name="testapp/profile/image.png", 416 | file_type="WEBP", 417 | aspect_ratio=Fraction(16, 9), 418 | storage=default_storage, 419 | width=800, 420 | ), 421 | PillowPicture( 422 | parent_name="testapp/profile/image.png", 423 | file_type="WEBP", 424 | aspect_ratio=Fraction(16, 9), 425 | storage=default_storage, 426 | width=300, 427 | ), 428 | PillowPicture( 429 | parent_name="testapp/profile/image.png", 430 | file_type="WEBP", 431 | aspect_ratio=Fraction(1, 1), 432 | storage=default_storage, 433 | width=300, 434 | ), 435 | PillowPicture( 436 | parent_name="testapp/profile/image.png", 437 | file_type="WEBP", 438 | aspect_ratio=Fraction(16, 9), 439 | storage=default_storage, 440 | width=600, 441 | ), 442 | PillowPicture( 443 | parent_name="testapp/profile/image.png", 444 | file_type="WEBP", 445 | aspect_ratio=Fraction(3, 2), 446 | storage=default_storage, 447 | width=700, 448 | ), 449 | PillowPicture( 450 | parent_name="testapp/profile/image.png", 451 | file_type="WEBP", 452 | aspect_ratio=Fraction(1, 1), 453 | storage=default_storage, 454 | width=400, 455 | ), 456 | PillowPicture( 457 | parent_name="testapp/profile/image.png", 458 | file_type="WEBP", 459 | aspect_ratio=Fraction(16, 9), 460 | storage=default_storage, 461 | width=400, 462 | ), 463 | PillowPicture( 464 | parent_name="testapp/profile/image.png", 465 | file_type="WEBP", 466 | aspect_ratio=Fraction(3, 2), 467 | storage=default_storage, 468 | width=400, 469 | ), 470 | PillowPicture( 471 | parent_name="testapp/profile/image.png", 472 | file_type="WEBP", 473 | aspect_ratio=Fraction(3, 2), 474 | storage=default_storage, 475 | width=200, 476 | ), 477 | PillowPicture( 478 | parent_name="testapp/profile/image.png", 479 | file_type="WEBP", 480 | aspect_ratio=None, 481 | storage=default_storage, 482 | width=300, 483 | ), 484 | PillowPicture( 485 | parent_name="testapp/profile/image.png", 486 | file_type="WEBP", 487 | aspect_ratio=Fraction(3, 2), 488 | storage=default_storage, 489 | width=600, 490 | ), 491 | PillowPicture( 492 | parent_name="testapp/profile/image.png", 493 | file_type="WEBP", 494 | aspect_ratio=Fraction(3, 2), 495 | storage=default_storage, 496 | width=100, 497 | ), 498 | PillowPicture( 499 | parent_name="testapp/profile/image.png", 500 | file_type="WEBP", 501 | aspect_ratio=Fraction(16, 9), 502 | storage=default_storage, 503 | width=200, 504 | ), 505 | PillowPicture( 506 | parent_name="testapp/profile/image.png", 507 | file_type="WEBP", 508 | aspect_ratio=None, 509 | storage=default_storage, 510 | width=400, 511 | ), 512 | PillowPicture( 513 | parent_name="testapp/profile/image.png", 514 | file_type="WEBP", 515 | aspect_ratio=Fraction(1, 1), 516 | storage=default_storage, 517 | width=500, 518 | ), 519 | }, 520 | ) 521 | 522 | @pytest.mark.django_db 523 | def test_save(self, stub_worker, image_upload_file): 524 | obj = SimpleModel(picture=image_upload_file) 525 | obj.save() 526 | stub_worker.join() 527 | 528 | assert default_storage.exists(obj.picture.name) 529 | assert obj.picture.aspect_ratios["16/9"]["WEBP"][100].path.exists() 530 | 531 | @pytest.mark.django_db 532 | def test_save_JPEG_RGA(self, stub_worker, image_upload_file): 533 | obj = JPEGModel(picture=image_upload_file) 534 | obj.save() 535 | stub_worker.join() 536 | 537 | assert default_storage.exists(obj.picture.name) 538 | assert obj.picture.aspect_ratios["16/9"]["JPEG"][100].path.exists() 539 | 540 | @pytest.mark.django_db 541 | def test_exif_transpose(self, stub_worker): 542 | img = Image.new("RGB", (600, 800), (255, 0, 0)) 543 | draw = ImageDraw.Draw(img) 544 | draw.rectangle((300, 0, 600, 800), fill=(0, 0, 255)) # blue is on the right 545 | exif = img.getexif() 546 | exif[0x0112] = 8 # pretend to be rotated by 90 degrees 547 | 548 | with io.BytesIO() as output: 549 | img.save(output, format="JPEG", exif=exif) 550 | image_file = SimpleUploadedFile("image.jpg", output.getvalue()) 551 | 552 | obj = SimpleModel(picture=image_file) 553 | obj.save() 554 | stub_worker.join() 555 | 556 | assert default_storage.exists(obj.picture.name) 557 | assert obj.picture.aspect_ratios["16/9"]["WEBP"][100].path.exists() 558 | with Image.open( 559 | obj.picture.aspect_ratios["16/9"]["WEBP"][100].path 560 | ) as img_small: 561 | assert img_small.size == (100, 56) 562 | pixels = img_small.load() 563 | assert pixels[0, 0] == (2, 0, 255) # blue is on the top, always blue! 564 | 565 | @pytest.mark.django_db 566 | def test_save__is_blank(self, monkeypatch): 567 | obj = SimpleModel() 568 | save_all = Mock() 569 | monkeypatch.setattr("pictures.models.PictureFieldFile.save_all", save_all) 570 | obj.save() 571 | assert not save_all.called 572 | 573 | @pytest.mark.django_db 574 | def test_delete(self, stub_worker, image_upload_file): 575 | obj = SimpleModel(picture=image_upload_file) 576 | obj.save() 577 | stub_worker.join() 578 | 579 | name = obj.picture.name 580 | path = obj.picture.aspect_ratios["16/9"]["WEBP"][100].path 581 | assert default_storage.exists(name) 582 | assert path.exists() 583 | 584 | obj.picture.delete() 585 | stub_worker.join() 586 | assert not default_storage.exists(name) 587 | assert not path.exists() 588 | 589 | @pytest.mark.django_db 590 | def test_update_all(self, stub_worker, image_upload_file): 591 | obj = SimpleModel(picture=image_upload_file) 592 | obj.save() 593 | stub_worker.join() 594 | 595 | name = obj.picture.name 596 | path = obj.picture.aspect_ratios["16/9"]["WEBP"][100].path 597 | assert default_storage.exists(name) 598 | assert path.exists() 599 | 600 | old = copy.deepcopy(obj.picture) 601 | with override_field_aspect_ratios(obj.picture.field, ["1/1"]): 602 | obj.picture.update_all(old) 603 | stub_worker.join() 604 | assert default_storage.exists(name) 605 | assert obj.picture.aspect_ratios["1/1"]["WEBP"][100].path.exists() 606 | assert not path.exists() 607 | 608 | @pytest.mark.django_db 609 | def test_width(self, stub_worker, image_upload_file): 610 | obj = SimpleModel(picture=image_upload_file) 611 | obj.save() 612 | obj.picture_width = None 613 | 614 | assert obj.picture.width == 800 615 | 616 | @pytest.mark.django_db 617 | def test_height(self, stub_worker, image_upload_file): 618 | obj = SimpleModel(picture=image_upload_file) 619 | obj.save() 620 | obj.picture_height = None 621 | 622 | assert obj.picture.height == 800 623 | 624 | @pytest.mark.django_db 625 | def test_update_all__empty(self, stub_worker, image_upload_file): 626 | obj = SimpleModel() 627 | obj.save() 628 | 629 | obj.picture.update_all(obj.picture) 630 | 631 | def test_delete_all__empty(self): 632 | obj = SimpleModel() 633 | obj.picture.delete_all() 634 | 635 | 636 | class TestPictureField: 637 | @pytest.mark.django_db 638 | def test_integration(self, image_upload_file): 639 | obj = SimpleModel.objects.create(picture=image_upload_file) 640 | assert obj.picture.aspect_ratios == { 641 | None: { 642 | "WEBP": { 643 | 800: PillowPicture( 644 | parent_name="testapp/simplemodel/image.png", 645 | file_type="WEBP", 646 | aspect_ratio=None, 647 | storage=default_storage, 648 | width=800, 649 | ), 650 | 100: PillowPicture( 651 | parent_name="testapp/simplemodel/image.png", 652 | file_type="WEBP", 653 | aspect_ratio=None, 654 | storage=default_storage, 655 | width=100, 656 | ), 657 | 200: PillowPicture( 658 | parent_name="testapp/simplemodel/image.png", 659 | file_type="WEBP", 660 | aspect_ratio=None, 661 | storage=default_storage, 662 | width=200, 663 | ), 664 | 300: PillowPicture( 665 | parent_name="testapp/simplemodel/image.png", 666 | file_type="WEBP", 667 | aspect_ratio=None, 668 | storage=default_storage, 669 | width=300, 670 | ), 671 | 400: PillowPicture( 672 | parent_name="testapp/simplemodel/image.png", 673 | file_type="WEBP", 674 | aspect_ratio=None, 675 | storage=default_storage, 676 | width=400, 677 | ), 678 | 500: PillowPicture( 679 | parent_name="testapp/simplemodel/image.png", 680 | file_type="WEBP", 681 | aspect_ratio=None, 682 | storage=default_storage, 683 | width=500, 684 | ), 685 | 600: PillowPicture( 686 | parent_name="testapp/simplemodel/image.png", 687 | file_type="WEBP", 688 | aspect_ratio=None, 689 | storage=default_storage, 690 | width=600, 691 | ), 692 | 700: PillowPicture( 693 | parent_name="testapp/simplemodel/image.png", 694 | file_type="WEBP", 695 | aspect_ratio=None, 696 | storage=default_storage, 697 | width=700, 698 | ), 699 | } 700 | }, 701 | "3/2": { 702 | "WEBP": { 703 | 800: PillowPicture( 704 | parent_name="testapp/simplemodel/image.png", 705 | file_type="WEBP", 706 | aspect_ratio=Fraction(3, 2), 707 | storage=default_storage, 708 | width=800, 709 | ), 710 | 100: PillowPicture( 711 | parent_name="testapp/simplemodel/image.png", 712 | file_type="WEBP", 713 | aspect_ratio=Fraction(3, 2), 714 | storage=default_storage, 715 | width=100, 716 | ), 717 | 200: PillowPicture( 718 | parent_name="testapp/simplemodel/image.png", 719 | file_type="WEBP", 720 | aspect_ratio=Fraction(3, 2), 721 | storage=default_storage, 722 | width=200, 723 | ), 724 | 300: PillowPicture( 725 | parent_name="testapp/simplemodel/image.png", 726 | file_type="WEBP", 727 | aspect_ratio=Fraction(3, 2), 728 | storage=default_storage, 729 | width=300, 730 | ), 731 | 400: PillowPicture( 732 | parent_name="testapp/simplemodel/image.png", 733 | file_type="WEBP", 734 | aspect_ratio=Fraction(3, 2), 735 | storage=default_storage, 736 | width=400, 737 | ), 738 | 500: PillowPicture( 739 | parent_name="testapp/simplemodel/image.png", 740 | file_type="WEBP", 741 | aspect_ratio=Fraction(3, 2), 742 | storage=default_storage, 743 | width=500, 744 | ), 745 | 600: PillowPicture( 746 | parent_name="testapp/simplemodel/image.png", 747 | file_type="WEBP", 748 | aspect_ratio=Fraction(3, 2), 749 | storage=default_storage, 750 | width=600, 751 | ), 752 | 700: PillowPicture( 753 | parent_name="testapp/simplemodel/image.png", 754 | file_type="WEBP", 755 | aspect_ratio=Fraction(3, 2), 756 | storage=default_storage, 757 | width=700, 758 | ), 759 | } 760 | }, 761 | "16/9": { 762 | "WEBP": { 763 | 800: PillowPicture( 764 | parent_name="testapp/simplemodel/image.png", 765 | file_type="WEBP", 766 | aspect_ratio=Fraction(16, 9), 767 | storage=default_storage, 768 | width=800, 769 | ), 770 | 100: PillowPicture( 771 | parent_name="testapp/simplemodel/image.png", 772 | file_type="WEBP", 773 | aspect_ratio=Fraction(16, 9), 774 | storage=default_storage, 775 | width=100, 776 | ), 777 | 200: PillowPicture( 778 | parent_name="testapp/simplemodel/image.png", 779 | file_type="WEBP", 780 | aspect_ratio=Fraction(16, 9), 781 | storage=default_storage, 782 | width=200, 783 | ), 784 | 300: PillowPicture( 785 | parent_name="testapp/simplemodel/image.png", 786 | file_type="WEBP", 787 | aspect_ratio=Fraction(16, 9), 788 | storage=default_storage, 789 | width=300, 790 | ), 791 | 400: PillowPicture( 792 | parent_name="testapp/simplemodel/image.png", 793 | file_type="WEBP", 794 | aspect_ratio=Fraction(16, 9), 795 | storage=default_storage, 796 | width=400, 797 | ), 798 | 500: PillowPicture( 799 | parent_name="testapp/simplemodel/image.png", 800 | file_type="WEBP", 801 | aspect_ratio=Fraction(16, 9), 802 | storage=default_storage, 803 | width=500, 804 | ), 805 | 600: PillowPicture( 806 | parent_name="testapp/simplemodel/image.png", 807 | file_type="WEBP", 808 | aspect_ratio=Fraction(16, 9), 809 | storage=default_storage, 810 | width=600, 811 | ), 812 | 700: PillowPicture( 813 | parent_name="testapp/simplemodel/image.png", 814 | file_type="WEBP", 815 | aspect_ratio=Fraction(16, 9), 816 | storage=default_storage, 817 | width=700, 818 | ), 819 | } 820 | }, 821 | } 822 | 823 | def test_check_aspect_ratios(self): 824 | assert not PictureField()._check_aspect_ratios() 825 | errors = PictureField(aspect_ratios=["not-a-ratio"])._check_aspect_ratios() 826 | assert errors 827 | assert errors[0].id == "fields.E100" 828 | 829 | def test_check_width_height_field(self): 830 | assert not PictureField(aspect_ratios=["3/2"])._check_width_height_field() 831 | with override_field_aspect_ratios(Profile.picture.field, [None]): 832 | errors = Profile.picture.field._check_width_height_field() 833 | assert errors 834 | assert errors[0].id == "fields.E101" 835 | assert errors[0].hint.startswith( 836 | "Please add two positive integer fields to 'testapp.Profile'" 837 | ) 838 | 839 | def test_check(self): 840 | assert not SimpleModel._meta.get_field("picture").check() 841 | assert Profile._meta.get_field("picture").check() 842 | -------------------------------------------------------------------------------- /tests/test_tasks.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from pictures import tasks 6 | from tests.testapp.models import SimpleModel 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_process_picture__file_cannot_be_reopened(image_upload_file): 11 | # regression https://github.com/codingjoe/django-pictures/issues/26 12 | obj = SimpleModel.objects.create(picture=image_upload_file) 13 | setattr( 14 | obj.picture.file, 15 | "open", 16 | Mock(side_effect=ValueError("The file cannot be reopened.")), 17 | ) 18 | tasks._process_picture( 19 | obj.picture.storage.deconstruct(), 20 | obj.picture.name, 21 | new=[i.deconstruct() for i in obj.picture.get_picture_files_list()], 22 | ) 23 | 24 | 25 | def test_noop(): 26 | tasks.noop() # does nothing 27 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pictures.templatetags.pictures import img_url, picture 4 | from tests.testapp.models import Profile 5 | 6 | picture_html = b""" 7 | 8 | 11 | Spiderman 12 | 13 | """ 14 | 15 | picture_html_large = b""" 16 | 17 | 20 | Spiderman 21 | 22 | """ 23 | 24 | picture_with_placeholders_html = b""" 25 | 26 | 29 | Spiderman 30 | 31 | """ 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_picture(client, image_upload_file, settings): 36 | settings.PICTURES["USE_PLACEHOLDERS"] = False 37 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 38 | response = client.get(profile.get_absolute_url()) 39 | assert response.status_code == 200 40 | assert picture_html in response.content 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_picture__large(client, large_image_upload_file, settings): 45 | settings.PICTURES["USE_PLACEHOLDERS"] = False 46 | # ensure that USE_THOUSAND_SEPARATOR doesn't break srcset with widths greater than 1000px 47 | settings.USE_THOUSAND_SEPARATOR = True 48 | profile = Profile.objects.create(name="Spiderman", picture=large_image_upload_file) 49 | response = client.get(profile.get_absolute_url()) 50 | assert response.status_code == 200 51 | assert picture_html_large in response.content 52 | 53 | 54 | @pytest.mark.django_db 55 | def test_picture__placeholder(client, image_upload_file, settings): 56 | settings.PICTURES["USE_PLACEHOLDERS"] = True 57 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 58 | response = client.get(profile.get_absolute_url()) 59 | assert response.status_code == 200 60 | assert picture_with_placeholders_html in response.content 61 | 62 | 63 | @pytest.mark.django_db 64 | def test_picture__placeholder_with_alt(client, image_upload_file, settings): 65 | settings.PICTURES["USE_PLACEHOLDERS"] = True 66 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 67 | html = picture( 68 | profile.picture, img_alt="Event 2022/2023", ratio="3/2", img_loading="lazy" 69 | ) 70 | assert "/_pictures/Event%25202022%252F2023/3x2/800w.WEBP" in html 71 | 72 | 73 | @pytest.mark.django_db 74 | def test_picture__invalid_ratio(image_upload_file): 75 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 76 | with pytest.raises(ValueError) as e: 77 | picture(profile.picture, ratio="4/3") 78 | assert "Invalid ratio: 4/3. Choices are: 1/1, 3/2, 16/9" in str(e.value) 79 | 80 | 81 | @pytest.mark.django_db 82 | def test_picture__additional_attrs_img(image_upload_file): 83 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 84 | html = picture(profile.picture, ratio="3/2", img_loading="lazy") 85 | assert ' loading="lazy"' in html 86 | 87 | 88 | @pytest.mark.django_db 89 | def test_picture__additional_attrs_img_size(image_upload_file): 90 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 91 | html = picture(profile.picture, ratio="3/2", img_width=500, img_height=500) 92 | assert ' width="500"' in html 93 | assert ' height="500"' in html 94 | 95 | 96 | @pytest.mark.django_db 97 | def test_picture__additional_attrs_picture(image_upload_file): 98 | profile = Profile.objects.create(name="Spiderman", picture=image_upload_file) 99 | html = picture(profile.picture, ratio="3/2", picture_class="picture-class") 100 | assert ' 2 | {% load pictures %} 3 | 4 | 5 | 6 | Testapp 7 | 8 | 9 | 43 | 44 | 45 | 46 | {% block content %}{% endblock %} 47 | 48 | 49 | -------------------------------------------------------------------------------- /tests/testapp/templates/testapp/profile_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "testapp/base.html" %} 2 | {% load pictures %} 3 | 4 | {% block content %} 5 |

{{ profile.name }}

6 |

{{ profile.description }}

7 | {% if profile.picture %} 8 | {% picture profile.picture profile.name m=6 l=4 %} 9 | {% endif %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("profile//", views.ProfileDetailView.as_view(), name="profile_detail"), 8 | path("_pictures/", include("pictures.urls")), 9 | path("admin/", admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.views import generic 2 | 3 | from . import models 4 | 5 | 6 | class ProfileDetailView(generic.DetailView): 7 | model = models.Profile 8 | --------------------------------------------------------------------------------