├── .coveragerc ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── Makefile ├── __init__.py ├── make.bat └── source │ ├── __init__.py │ ├── _static │ ├── code_structure.png │ ├── logo.png │ ├── logo.svg │ ├── specificity_1_2_default_error.png │ ├── specificity_1_custom_error.png │ ├── specificity_1_default_error.png │ ├── specificity_3_custom_error.png │ ├── specificity_3_default_error.png │ └── structure.svg │ ├── authors.rst │ ├── conf.py │ ├── configuration.rst │ ├── contributing.rst │ ├── examples.rst │ ├── history.rst │ ├── how_it_works.rst │ ├── index.rst │ ├── installation.rst │ ├── readme.rst │ ├── reference.rst │ └── usage.rst ├── requirements.txt ├── requirements_dev.txt ├── requirements_test.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── apps │ ├── __init__.py │ ├── myapp │ │ ├── __init__.py │ │ ├── apps.py │ │ └── models.py │ ├── myapp2 │ │ ├── __init__.py │ │ ├── apps.py │ │ └── models │ │ │ ├── __init__.py │ │ │ └── models.py │ └── no_model │ │ ├── __init__.py │ │ └── apps.py ├── images │ └── 500x498-100KB.jpeg ├── requirements.txt ├── settings.py └── test_suites │ ├── __init__.py │ ├── const.py │ ├── test_checker.py │ ├── test_core_init.py │ ├── test_validation_rule_aspect_ratio.py │ ├── test_validation_rule_base.py │ ├── test_validation_rule_dimensions.py │ ├── test_validation_rule_format.py │ ├── test_validation_rule_size.py │ ├── test_vimage_config.py │ ├── test_vimage_entry.py │ ├── test_vimage_key.py │ └── test_vimage_value.py ├── tox.ini └── vimage ├── __init__.py ├── apps.py ├── core ├── __init__.py ├── base.py ├── checker.py ├── const.py ├── exceptions.py └── validator_types.py └── locale └── el └── LC_MESSAGES ├── django.mo └── django.po /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | source = django_vimage 4 | 5 | [report] 6 | omit = 7 | vimage/apps.py 8 | *site-packages* 9 | *tests* 10 | *.tox* 11 | show_missing = True 12 | exclude_lines = 13 | raise NotImplementedError 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * django-vimage version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | htmlcov 29 | 30 | # Complexity 31 | output/*.html 32 | output/*/index.html 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3.6 3 | 4 | env: 5 | - TOX_ENV=py36-django2 6 | - TOX_ENV=py36-django3 7 | 8 | matrix: 9 | fast_finish: true 10 | 11 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 12 | install: pip install -r requirements_test.txt 13 | 14 | # command to run tests using coverage, e.g. python setup.py test 15 | script: tox -e $TOX_ENV 16 | 17 | after_success: 18 | - codecov -e TOX_ENV 19 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | .. _authors: 2 | 3 | Credits 4 | ======= 5 | 6 | Development Lead 7 | ---------------- 8 | 9 | * Nick Mavrakis 10 | 11 | Contributors 12 | ------------ 13 | 14 | None yet. Why not be the first? 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | Contributing 4 | ============ 5 | 6 | Contributions are welcome, and they are greatly appreciated! Every 7 | little bit helps, and credit will always be given. 8 | 9 | You can contribute in many ways: 10 | 11 | Types of Contributions 12 | ---------------------- 13 | 14 | Report Bugs 15 | ~~~~~~~~~~~ 16 | 17 | Report bugs at https://github.com/manikos/django-vimage/issues. 18 | 19 | If you are reporting a bug, please include: 20 | 21 | * Your operating system name and version. 22 | * Any details about your local setup that might be helpful in troubleshooting. 23 | * Detailed steps to reproduce the bug. 24 | 25 | Fix Bugs 26 | ~~~~~~~~ 27 | 28 | Look through the GitHub issues for bugs. Anything tagged with "bug" 29 | is open to whoever wants to implement it. 30 | 31 | Implement Features 32 | ~~~~~~~~~~~~~~~~~~ 33 | 34 | Look through the GitHub issues for features. Anything tagged with "feature" 35 | is open to whoever wants to implement it. 36 | 37 | .. _extend-translations: 38 | 39 | Extend translations 40 | ~~~~~~~~~~~~~~~~~~~ 41 | 42 | The only languages that the default validation error appears is in English and Greek. 43 | You may pull request translations in order to extend the ``locale/`` dir to other languages too! 44 | The number of strings that need to be translated is small, so you won't spend too much time. 45 | 46 | Write Documentation 47 | ~~~~~~~~~~~~~~~~~~~ 48 | 49 | django-vimage could always use more documentation, whether as part of the 50 | official django-vimage docs, in docstrings, or even on the web in blog posts, 51 | articles, and such. 52 | 53 | Submit Feedback 54 | ~~~~~~~~~~~~~~~ 55 | 56 | The best way to send feedback is to file an issue at https://github.com/manikos/django-vimage/issues. 57 | 58 | If you are proposing a feature: 59 | 60 | * Explain in detail how it would work. 61 | * Keep the scope as narrow as possible, to make it easier to implement. 62 | * Remember that this is a volunteer-driven project, and that contributions 63 | are welcome :) 64 | 65 | Get Started! 66 | ------------ 67 | 68 | Ready to contribute? Here's how to set up `django-vimage` for local development. 69 | 70 | 1. Fork the `django-vimage` repo on GitHub. 71 | 2. Clone your fork locally:: 72 | 73 | $ git clone git@github.com:your_name_here/django-vimage.git 74 | 75 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 76 | 77 | $ mkvirtualenv django-vimage 78 | $ cd django-vimage/ 79 | $ python setup.py develop 80 | 81 | 4. Create a branch for local development:: 82 | 83 | $ git checkout -b name-of-your-bugfix-or-feature 84 | 85 | Now you can make your changes locally. 86 | 87 | 5. When you're done making changes, check that your changes pass flake8 and the 88 | tests, including testing other Python versions with tox:: 89 | 90 | $ flake8 vimage tests 91 | $ python setup.py test 92 | $ tox 93 | 94 | To get flake8 and tox, just pip install them into your virtualenv. 95 | 96 | 6. Commit your changes and push your branch to GitHub:: 97 | 98 | $ git add . 99 | $ git commit -m "Your detailed description of your changes." 100 | $ git push origin name-of-your-bugfix-or-feature 101 | 102 | 7. Submit a pull request through the GitHub website. 103 | 104 | Pull Request Guidelines 105 | ----------------------- 106 | 107 | Before you submit a pull request, check that it meets these guidelines: 108 | 109 | 1. The pull request should include tests. 110 | 2. If the pull request adds functionality, the docs should be updated. Put 111 | your new functionality into a function with a docstring, and add the 112 | feature to the list in README.rst. 113 | 3. The pull request should work for Python 2.6, 2.7, and 3.3, and for PyPy. Check 114 | https://travis-ci.org/manikos/django-vimage/pull_requests 115 | and make sure that the tests pass for all supported Python versions. 116 | 117 | Tips 118 | ---- 119 | 120 | To run a subset of tests:: 121 | 122 | $ python runtests.py test_checker # will run only tests/test_suites/test_checker.py 123 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | .. _history: 4 | 5 | History 6 | ======= 7 | 8 | 0.1.0 (2018-04-17) 9 | ------------------ 10 | 11 | - First release on PyPI 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2018, Nick Mavrakis 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense 8 | and/or sell copies of the Software and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 14 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 15 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include vimage *.po *.mo 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## check style with flake8 31 | flake8 vimage tests 32 | 33 | test: ## run tests quickly with the default Python 34 | python runtests.py 35 | 36 | test-all: ## run tests on every Python version with tox 37 | tox 38 | 39 | coverage: ## check code coverage quickly with the default Python 40 | coverage run --source vimage runtests.py 41 | coverage report -m 42 | 43 | docs: ## generate Sphinx documentation 44 | rm -rf docs/build/* 45 | sphinx-build -n docs/source/ docs/build/html 46 | $(BROWSER) docs/build/html/index.html 47 | 48 | release: clean ## package and upload a release 49 | python setup.py sdist upload 50 | python setup.py bdist_wheel upload 51 | 52 | sdist: clean ## package 53 | python setup.py sdist 54 | ls -l dist 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](docs/source/_static/logo.png) django-vimage 2 | ==================================================== 3 | 4 | [![Latest PyPI version badge](https://img.shields.io/pypi/v/django-vimage.svg?style=flat-square)](https://pypi.org/project/django-vimage/) 5 | [![Travis CI build status badge](https://img.shields.io/travis/manikos/django-vimage/master.svg?style=flat-square)](https://travis-ci.org/manikos/django-vimage) 6 | [![Codecov status badge](https://img.shields.io/codecov/c/github/manikos/django-vimage.svg?style=flat-square)](https://codecov.io/gh/manikos/django-vimage) 7 | [![ReadTheDocs documentation status badge](https://img.shields.io/readthedocs/django-vimage.svg?style=flat-square)](https://readthedocs.org/projects/django-vimage/) 8 | [![Supported python versions badge](https://img.shields.io/pypi/pyversions/django-vimage.svg?style=flat-square)](https://pypi.org/project/django-vimage/) 9 | [![Supported Django versions badge](https://img.shields.io/pypi/djversions/django-vimage.svg?style=flat-square)](https://pypi.org/project/django-vimage/) 10 | [![License badge](https://img.shields.io/github/license/manikos/django-vimage.svg?style=flat-square)](https://github.com/manikos/django-vimage/bolb/master/LICENSE) 11 | 12 | Django Image validation for the [Django Admin](https://docs.djangoproject.com/en/dev/ref/contrib/admin/) as a breeze. 13 | Validations on: Size, Dimensions, Format and Aspect Ratio. 14 | 15 | Because, I love to look for the origin of a word/band/place/something, 16 | this package name comes from the word *validate* and (you guessed it) 17 | *image*. Thus, `django-vimage`. Nothing more, nothing less :) 18 | 19 | This package was created due to lack of similar Django packages that do 20 | image validation. I searched for this but found nothing. So, I decided 21 | to create a reusable Django package that will do image validation in a 22 | simple manner. Just declare some `ImageField`s and the rules to apply to 23 | them in a simple Python dictionary. Firstly, I wrote the blueprint on a 24 | piece of paper and then I, gradually, ported it to Django/Python code. 25 | 26 | Documentation 27 | ------------- 28 | 29 | The full documentation is at [https://django-vimage.readthedocs.io](https://django-vimage.readthedocs.io). 30 | 31 | Quickstart 32 | ---------- 33 | 34 | Install django-vimage : 35 | 36 | pip install django-vimage 37 | 38 | Add it to your `INSTALLED_APPS` : 39 | 40 | INSTALLED_APPS = ( 41 | ... 42 | 'vimage.apps.VimageConfig', 43 | ... 44 | ) 45 | 46 | Finally, add the `VIMAGE` dict configuration somewhere in your `settings` file : 47 | 48 | VIMAGE = { 49 | 'my_app.models': { 50 | 'DIMENSIONS': (200, 200), 51 | 'SIZE': {'lt': 100}, 52 | } 53 | } 54 | 55 | The above `VIMAGE` setting sets the rules for all Django 56 | `ImageField` fields under the `my_app` app. More particular, all 57 | `ImageField`s should be 200 x 200px **and** less than 100KB. Any image 58 | than violates any of the above rules, a nice-looking error message will 59 | be shown (translated accordingly) in the Django admin page. 60 | 61 | A full example of possible key:value pairs is shown below. Note that the 62 | following code block is not suitable for copy-paste into your `settings` 63 | file since it contains duplicate dict keys. It's just for demonstration. 64 | 65 | ```python 66 | VIMAGE = { 67 | # Possible keys are: 68 | # 'app.models' # to all ImageFields inside this app 69 | # 'app.models.MyModel' # to all ImageFields inside MyModel 70 | # 'app.models.MyModel.field' # only to this ImageField 71 | 72 | # Example of applying validation rules to all images across 73 | # all models of myapp app 74 | 'myapp.models': { 75 | # rules 76 | }, 77 | 78 | # Example of applying validation rules to all images across 79 | # a specific model 80 | 'myapp.models.MyModel': { 81 | # rules 82 | }, 83 | 84 | # Example of applying validation rules to a 85 | # specific ImageField field 86 | 'myapp.models.MyModel.img': { 87 | # rules 88 | }, 89 | 90 | # RULES 91 | 'myapp.models': { 92 | 93 | # By size (measured in KB) 94 | 95 | # Should equal to 100KB 96 | 'SIZE': 100, # defaults to eq (==) 97 | 98 | # (100KB <= image_size <= 200KB) AND not equal to 150KB 99 | 'SIZE': { 100 | 'gte': 100, 101 | 'lte': 200, 102 | 'ne': 150, 103 | }, 104 | 105 | # Custom error message 106 | 'SIZE': { 107 | 'gte': 100, 108 | 'lte': 200, 109 | 'err': 'Your own error message instead of the default.' 110 | 'Supports html tags too!', 111 | }, 112 | 113 | 114 | # By dimensions (measured in px) 115 | # Should equal to 1200x700px (width x height) 116 | 'DIMENSIONS': (1200, 700), # defaults to eq (==) 117 | 118 | # Should equal to one of these sizes 1000x300px or 1500x350px 119 | 'DIMENSIONS': [(1000, 300), (1500, 350)], 120 | 121 | # Should be 1000x300 <= image_dimensions <= 2000x500px 122 | 'DIMENSIONS': { 123 | 'gte': (1000, 300), 124 | 'lte': (2000, 500), 125 | }, 126 | 127 | # width must be >= 30px and less than 60px 128 | # height must be less than 90px and not equal to 40px 129 | 'DIMENSIONS': { 130 | 'w': { 131 | 'gt': 30, 132 | 'lt': 60, 133 | }, 134 | 'h': { 135 | 'lt': 90, 136 | 'ne': 40, 137 | } 138 | }, 139 | 140 | 141 | # By format (jpeg, png, tiff etc) 142 | # Uploaded image should be JPEG 143 | 'FORMAT': 'jpeg', 144 | 145 | # Uploaded image should be one of the following 146 | 'FORMAT': ['jpeg', 'png', 'gif'], 147 | 148 | # Uploaded image should not be a GIF 149 | 'FORMAT': { 150 | 'ne': 'gif', 151 | }, 152 | 153 | # Uploaded image should be neither a GIF nor a PNG 154 | 'FORMAT': { 155 | 'ne': ['gif', 'png'], 156 | 'err': 'Wrong image format!' 157 | }, 158 | } 159 | } 160 | ``` 161 | 162 | Features 163 | -------- 164 | 165 | - An image may be validated against it's size (KB), dimensions (px), 166 | format (jpeg, png etc) and aspect ratio (width/height ratio). 167 | 168 | - Well formatted error messages. They have the form of: 169 | 170 | **[IMAGE RULE\_NAME]** Validation error: **image_value** does not meet validation rule: **rule**. 171 | 172 | - Humanized error messages. All rules and image values are *humanized*: 173 | 174 | - `'SIZE': {'gte': 100}` becomes `greater than or equal to 100KB` when rendered 175 | 176 | - `'DIMENSIONS': {'ne': (100, 100)}` becomes `not equal to 100 x 100px` when rendered 177 | 178 | - Overridable error messages. The default error messages may be overridden by defining an `err` key inside the validation rules: 179 | 180 | `'SIZE': {'gte': 100, 'err': 'Custom error'}` becomes 181 | `Custom error` when rendered 182 | 183 | - HTML-safe (custom) error messages. All error messages (the default or your own) are passed through the function [mark_safe](https://docs.djangoproject.com/en/dev/ref/utils/#django.utils.safestring.mark_safe). 184 | 185 | - Cascading validation rules. It's possible to define a generic rule 186 | to some `ImageField` fields of an app and then define another set of 187 | rules to a specific `ImageField` field. Common rules will override 188 | the generic ones and any new rules will be added to the specific 189 | `ImageField` field: 190 | 191 | myapp.models: { 192 | 'SIZE': { 193 | 'lt': 120, 194 | }, 195 | 'FORMAT': 'jpeg', 196 | 'DIMENSIONS': { 197 | 'lt': (500, 600), 198 | } 199 | }, 200 | myapp.models.MyModel.img: { 201 | 'DIMENSIONS': (1000, 500), 202 | }, 203 | 204 | In the example above (the order does not matter), all `ImageField`s 205 | should be `less than 120KB`, `JPEG` images **and** `less than 500 x 600px`. 206 | However, the `myapp.models.MyModel.img` field should be `less than 120KB`, `JPEG` image **and** `equal to 1000 x 500px`. 207 | 208 | Running Tests 209 | ------------- 210 | 211 | Does the code actually work? 212 | 213 | source /bin/activate 214 | (myenv) $ pip install tox 215 | (myenv) $ tox 216 | 217 | Future additions 218 | ---------------- 219 | 220 | - Validation of image mode (whether the uploaded image is in indexed 221 | mode, greyscale mode etc) based on [image's mode](http://pillow.readthedocs.io/en/latest/handbook/concepts.html#modes). 222 | This is quite easy to implement but rather a *rare* validation 223 | requirement. Thus, it'll be implemented if users want to validate 224 | the mode of the image (which again, it's rare for the web). 225 | - If you think of any other validation (apart from svg) that may be 226 | applied to an image and it's not included in this package, please 227 | feel free to submit an issue or a PR. 228 | 229 | Credits 230 | ------- 231 | 232 | Tools used in rendering this package: 233 | 234 | - [Cookiecutter](https://github.com/audreyr/cookiecutter) 235 | - [cookiecutter-djangopackage](https://github.com/pydanny/cookiecutter-djangopackage) 236 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-vimage 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/__init__.py -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=django-vimage 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/__init__.py -------------------------------------------------------------------------------- /docs/source/_static/code_structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/code_structure.png -------------------------------------------------------------------------------- /docs/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/logo.png -------------------------------------------------------------------------------- /docs/source/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 25 | 27 | image/svg+xml 28 | 30 | 31 | 32 | 33 | 34 | 36 | 60 | 63 | 66 | 71 | 76 | 81 | 86 | 91 | 96 | 97 | 103 | 104 | 107 | 109 | 112 | 118 | 124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /docs/source/_static/specificity_1_2_default_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/specificity_1_2_default_error.png -------------------------------------------------------------------------------- /docs/source/_static/specificity_1_custom_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/specificity_1_custom_error.png -------------------------------------------------------------------------------- /docs/source/_static/specificity_1_default_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/specificity_1_default_error.png -------------------------------------------------------------------------------- /docs/source/_static/specificity_3_custom_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/specificity_3_custom_error.png -------------------------------------------------------------------------------- /docs/source/_static/specificity_3_default_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/docs/source/_static/specificity_3_default_error.png -------------------------------------------------------------------------------- /docs/source/_static/structure.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | VIMAGE = { 'myapp.models': { 'SIZE': {'lt': 200}, 'DIMENSIONS': (1920, 1080), },} 120 | 129 | 138 | 147 | 156 | 165 | core.base.VimageConfig core.base.VimageEntry core.base.VimageValue core.base.VimageKey core.validator_types.ValidationRuleDimensions core.validator_types.ValidationRuleSize 238 | 239 | -------------------------------------------------------------------------------- /docs/source/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../..')) 18 | autodoc_mock_imports = ['django'] 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'django-vimage' 24 | copyright = '2018, Nick Mavrakis' 25 | author = 'Nick Mavrakis' 26 | config_name = 'VIMAGE' 27 | rst_epilog = '.. |config_name| replace:: ``%s``' % config_name 28 | 29 | # The short X.Y version 30 | version = '' 31 | # The full version, including alpha/beta/rc tags 32 | release = '0.1.0' 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | # 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 43 | extensions = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.intersphinx', 46 | 'sphinx.ext.todo', 47 | 'sphinx.ext.coverage', 48 | 'sphinx.ext.viewcode', 49 | ] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix(es) of source filenames. 55 | # You can specify multiple suffix as a list of string: 56 | # 57 | # source_suffix = ['.rst', '.md'] 58 | source_suffix = '.rst' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = 'en' 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This pattern also affects html_static_path and html_extra_path . 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | # pygments_style = 'sphinx' 77 | pygments_style = 'emacs' 78 | 79 | 80 | # -- Options for HTML output ------------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | html_theme = 'alabaster' 85 | 86 | 87 | # Theme options are theme-specific and customize the look and feel of a theme 88 | # further. For a list of options available for each theme, see the 89 | # documentation. 90 | # 91 | # html_theme_options = {} 92 | html_theme_options = { 93 | 'logo': 'logo.png', 94 | 'logo_name': True, # show project name under the logo 95 | 'description': 'Image validation for Django Admin', 96 | 'github_user': 'manikos', 97 | 'github_repo': 'django-vimage', 98 | 'page_width': '1080px', # set content/page width 99 | 'sidebar_width': '265px', 100 | 'fixed_sidebar': True, 101 | 'show_related': True, # show next/previous links (above "Quick search") 102 | } 103 | 104 | # Add any paths that contain custom static files (such as style sheets) here, 105 | # relative to this directory. They are copied after the builtin static files, 106 | # so a file named "default.css" will overwrite the builtin "default.css". 107 | html_static_path = ['_static'] 108 | 109 | # Custom sidebar templates, must be a dictionary that maps document names 110 | # to template names. 111 | # 112 | # The default sidebars (for documents that don't match any pattern) are 113 | # defined by theme itself. Builtin themes are using these templates by 114 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 115 | # 'searchbox.html']``. 116 | # 117 | # html_sidebars = {} 118 | html_sidebars = { 119 | '**': [ 120 | 'about.html', 121 | 'navigation.html', 122 | 'relations.html', 123 | 'searchbox.html', 124 | ] 125 | } 126 | 127 | 128 | # -- Options for HTMLHelp output --------------------------------------------- 129 | 130 | # Output file base name for HTML help builder. 131 | htmlhelp_basename = 'django-vimagedoc' 132 | 133 | 134 | # -- Options for LaTeX output ------------------------------------------------ 135 | 136 | latex_elements = { 137 | # The paper size ('letterpaper' or 'a4paper'). 138 | # 139 | # 'papersize': 'letterpaper', 140 | 141 | # The font size ('10pt', '11pt' or '12pt'). 142 | # 143 | # 'pointsize': '10pt', 144 | 145 | # Additional stuff for the LaTeX preamble. 146 | # 147 | # 'preamble': '', 148 | 149 | # Latex figure (float) alignment 150 | # 151 | # 'figure_align': 'htbp', 152 | } 153 | 154 | # Grouping the document tree into LaTeX files. List of tuples 155 | # (source start file, target name, title, 156 | # author, documentclass [howto, manual, or own class]). 157 | latex_documents = [ 158 | (master_doc, 'django-vimage.tex', 'django-vimage Documentation', 159 | 'Nick Mavrakis', 'manual'), 160 | ] 161 | 162 | 163 | # -- Options for manual page output ------------------------------------------ 164 | 165 | # One entry per manual page. List of tuples 166 | # (source start file, name, description, authors, manual section). 167 | man_pages = [ 168 | (master_doc, 'django-vimage', 'django-vimage Documentation', 169 | [author], 1) 170 | ] 171 | 172 | 173 | # -- Options for Texinfo output ---------------------------------------------- 174 | 175 | # Grouping the document tree into Texinfo files. List of tuples 176 | # (source start file, target name, title, author, 177 | # dir menu entry, description, category) 178 | texinfo_documents = [ 179 | (master_doc, 'django-vimage', 'django-vimage Documentation', 180 | author, 'django-vimage', 'One line description of project.', 181 | 'Miscellaneous'), 182 | ] 183 | 184 | 185 | # -- Extension configuration ------------------------------------------------- 186 | 187 | # -- Options for intersphinx extension --------------------------------------- 188 | 189 | # Example configuration for intersphinx: refer to the Python standard library. 190 | intersphinx_mapping = { 191 | 'python': ('https://docs.python.org/3', None), 192 | 'sphinx': ('http://www.sphinx-doc.org/en/master/', None), 193 | 'django': ('https://docs.djangoproject.com/en/dev/', 194 | 'https://docs.djangoproject.com/en/dev/_objects/'), 195 | } 196 | 197 | # -- Options for todo extension ---------------------------------------------- 198 | 199 | # If true, `todo` and `todoList` produce output, else they produce nothing. 200 | todo_include_todos = True 201 | -------------------------------------------------------------------------------- /docs/source/configuration.rst: -------------------------------------------------------------------------------- 1 | .. _configuration: 2 | 3 | Configuration 4 | ============= 5 | 6 | Each of the following strings are valid as a dict ``key`` of the |config_name| dict ``value``. 7 | Confused? Take a look at a :ref:`definition example ` of |config_name|. 8 | 9 | .. note:: The developer may provide a custom error which will be automatically HTML escaped. 10 | The string may also be :ref:`translated `. 11 | In order to do that, the value of the validation string must be a ``dict`` and the key of the custom error should be ``'err'``. Example: 12 | :name: custom_error_note 13 | 14 | .. code-block:: python 15 | :name: custom_error_message 16 | 17 | from django.utils.translation import gettext_lazy as _ 18 | 19 | VIMAGE = { 20 | 'myapp.models': { 21 | 'SIZE': { 22 | 'lt': 200, 23 | 'err': _('Size should be less than 200KB!'), 24 | }, 25 | } 26 | } 27 | 28 | .. note:: If ``'err'`` is not defined then a well-looking default error will appear. 29 | :name: default_error_note 30 | 31 | **[IMAGE {rule_name}]** Validation error: **{value}** does not meet validation rule: **{rule}**. 32 | 33 | 1. ``{rule_name}`` is replaced by the corresponding validation string 34 | 2. ``{value}`` is replaced by the corresponding image value under test 35 | 3. ``{rule}`` is replaced by the corresponding rule in a *humanized* form 36 | 37 | 38 | .. _validation_string_size: 39 | 40 | ---------- 41 | ``'SIZE'`` 42 | ---------- 43 | 44 | The ``'SIZE'`` key corresponds to the image's file size (measured in ``KB``). It accepts two kind of value types: ``int`` or ``dict``. 45 | 46 | - If it's an ``int`` (must be a positive integer) then it is assumed that the file size of the uploaded image will be **equal** to the value defined. 47 | 48 | .. code-block:: python 49 | :caption: 'SIZE' with ``int`` as value 50 | :name: size_w_int 51 | 52 | VIMAGE = { 53 | 'myapp.models': { 54 | # uploaded image file size should be equal to 100KB 55 | 'SIZE': 100, 56 | } 57 | } 58 | 59 | - If it's a ``dict``, then any ``str`` from :ref:`operator strings table ` will be valid as long as it's value is an ``int`` (positive integer). 60 | Also, take a look at this :ref:`note `. 61 | 62 | .. code-block:: python 63 | :caption: 'SIZE' with ``dict`` as value 64 | :name: size_w_dict 65 | 66 | VIMAGE = { 67 | 'myapp.models': { 68 | # uploaded image file size should be less than 200KB 69 | # and greater than 20KB 70 | 'SIZE': { 71 | 'lt': 200, 72 | 'gt': 20, 73 | 'err': 'custom error here' # optional 74 | }, 75 | } 76 | } 77 | 78 | 79 | .. _validation_string_dimensions: 80 | 81 | ---------------- 82 | ``'DIMENSIONS'`` 83 | ---------------- 84 | 85 | The ``'DIMENSIONS'`` key corresponds to the image's dimensions, width and height (measured in ``px``). It accepts three kind of value types: ``tuple``, ``list`` or ``dict``. 86 | 87 | - If it's a ``tuple`` (two-length tuple with positive integers) then it is assumed that the dimensions of the uploaded image will be **equal** to 88 | the value (tuple) defined (``(width, height)``). 89 | 90 | .. code-block:: python 91 | :caption: 'DIMENSIONS' with ``tuple`` as value 92 | :name: dimensions_w_tuple 93 | 94 | VIMAGE = { 95 | 'myapp.models': { 96 | # uploaded image dimensions should be equal to 800 x 600px 97 | # width == 800 and height == 600px 98 | 'DIMENSIONS': (800, 600), 99 | } 100 | } 101 | 102 | - If it's a ``list`` (one or more two-length tuples with positive integers) then it is assumed that the dimensions of the uploaded image will be **equal** to 103 | one of the values defined in the list. 104 | 105 | .. code-block:: python 106 | :caption: 'DIMENSIONS' with ``list`` as value 107 | :name: dimensions_w_list 108 | 109 | VIMAGE = { 110 | 'myapp.models': { 111 | # uploaded image dimensions should be equal to one of the 112 | # following: 800x600px, 500x640px or 100x100px. 113 | 'DIMENSIONS': [(800, 600), (500, 640), (100, 100)], 114 | } 115 | } 116 | 117 | - If it's a ``dict``, then there are two cases. Either use :ref:`operator strings table ` for keys and a two-length tuple of positive integers for values or 118 | use the strings ``'w'`` and/or ``'h'`` for keys and (another) ``dict`` for the value of each one using :ref:`operator strings table ` for keys and 119 | a positive integer for values. Confused? Below are two examples that cover each case. 120 | 121 | .. code-block:: python 122 | :caption: 'DIMENSIONS' with ``dict`` as value and tuples as sub-values 123 | :name: dimensions_w_dict_tuple 124 | 125 | VIMAGE = { 126 | 'myapp.models': { 127 | # uploaded image dimensions should be less than 1920x1080px 128 | # and greater than 800x768px. 129 | 'DIMENSIONS': { 130 | 'lt': (1920, 1080), 131 | 'gt': (800, 768), 132 | 'err': 'custom error here', # optional 133 | }, 134 | } 135 | } 136 | 137 | .. code-block:: python 138 | :caption: 'DIMENSIONS' with ``dict`` as value and ``'w'``, ``'h'`` as sub-keys 139 | :name: dimensions_w_dict_width_height 140 | 141 | VIMAGE = { 142 | 'myapp.models': { 143 | # uploaded image width should not be equal to 800px and 144 | # height should be greater than 600px. 145 | 'DIMENSIONS': { 146 | 'w': { 147 | 'ne': 800, # set rule just for width 148 | 'err': 'custom error here', # optional 149 | }, 150 | 'h': { 151 | 'gt': 600, # set rule just for height 152 | 'err': 'custom error here', # optional 153 | } 154 | }, 155 | } 156 | } 157 | 158 | .. note:: For custom error to work when defining both ``'w'`` and ``'h'``, the ``'err'`` entry should be placed to both ``'w'`` and ``'h'`` dicts. 159 | 160 | .. _validation_string_format: 161 | 162 | ------------ 163 | ``'FORMAT'`` 164 | ------------ 165 | 166 | The ``'FORMAT'`` key corresponds to the image's format (it doesn't have a measure unit since it's just a string), i.e ``'jpeg'``, ``'png'``, ``'webp'`` etc. 167 | Taking into account `what image formats the browsers support `_ 168 | |config_name| allows the most used formats for the web, which are: ``'jpeg'``, ``'png'``, ``'gif'``, ``'bmp'`` and ``'webp'``. 169 | It accepts three kind of value types: ``str``, ``list`` or ``dict``. 170 | 171 | - If it's a ``str`` then it is assumed that the format of the uploaded image will be **equal** to the value (``str``) defined. 172 | 173 | .. code-block:: python 174 | :caption: 'FORMAT' with ``str`` as value 175 | :name: format_w_str 176 | 177 | VIMAGE = { 178 | 'myapp.models': { 179 | # uploaded image format should be 'jpeg' 180 | 'FORMAT': 'jpeg', 181 | } 182 | } 183 | 184 | - If it's a ``list`` (list of strings) then it is assumed that the format of the uploaded image will be **equal** to one of the values defined in the list. 185 | 186 | .. code-block:: python 187 | :caption: 'FORMAT' with ``list`` as value 188 | :name: format_w_list 189 | 190 | VIMAGE = { 191 | 'myapp.models': { 192 | # uploaded image format should be one of the following: 193 | # 'jpeg', 'png' or 'webp'. 194 | 'FORMAT': ['jpeg', 'png', 'webp'] 195 | } 196 | } 197 | 198 | - If it's a ``dict``, then the keys must be either ``'eq'`` or ``'ne'`` (since the other operators cannot apply to ``str`` values) and as for the values they may 199 | be either a ``list`` or a ``str``. 200 | 201 | .. code-block:: python 202 | :caption: 'FORMAT' with ``dict`` as value and str as sub-value 203 | :name: format_w_dict_str 204 | 205 | VIMAGE = { 206 | 'myapp.models': { 207 | # uploaded image format should not be 'png'. 208 | 'FORMAT': { 209 | 'ne': 'png', 210 | 'err': 'custom error here', # optional 211 | }, 212 | } 213 | } 214 | 215 | .. code-block:: python 216 | :caption: 'FORMAT' with ``dict`` as value and list as sub-value 217 | :name: format_w_dict_list 218 | 219 | VIMAGE = { 220 | 'myapp.models': { 221 | # uploaded image format should not be equal to 222 | # neither `webp` nor 'bmp'. 223 | 'FORMAT': { 224 | 'ne': ['webp', 'bmp'], 225 | 'err': 'custom error here', # optional 226 | }, 227 | } 228 | } 229 | 230 | 231 | .. _validation_string_aspect_ratio: 232 | 233 | ------------------ 234 | ``'ASPECT_RATIO'`` 235 | ------------------ 236 | 237 | The ``'ASPECT_RATIO'`` key corresponds to the image's width to height ratio (it doesn't have a measure unit since it's just a decimal number). 238 | It accepts two kind of value types: ``float`` or ``dict``. 239 | 240 | - If it's a ``float`` (positive) then it is assumed that the aspect ratio of the uploaded image will be **equal** to the value (``float``) defined. 241 | 242 | .. code-block:: python 243 | :caption: 'ASPECT_RATIO' with ``float`` as value 244 | :name: aspect_ratio_w_float 245 | 246 | VIMAGE = { 247 | 'myapp.models': { 248 | # uploaded image aspect ratio should be equal to 1.2 249 | 'ASPECT_RATIO': 1.2, 250 | } 251 | } 252 | 253 | - If it's a ``dict``, then any ``str`` from :ref:`operator strings table ` will be valid as long as it's value is a positive ``float``. 254 | Also, take a look at this :ref:`note `. 255 | 256 | .. code-block:: python 257 | :caption: 'ASPECT_RATIO' with ``dict`` as value 258 | :name: aspect_ratio_w_dict 259 | 260 | VIMAGE = { 261 | 'myapp.models': { 262 | # uploaded image aspect ratio should be less than 1.2 263 | 'ASPECT_RATIO': { 264 | 'lt': 2.1, 265 | 'err': 'custom error here', # optional 266 | }, 267 | } 268 | } 269 | 270 | 271 | If you are a *table-person* maybe this will help you: 272 | 273 | .. table:: Summarized table between validation strings and their dict values 274 | :name: validation_string_summarized_table 275 | 276 | +----------------------+-----------------------------------------------------------------------------------------------------+ 277 | | Key | Value type | 278 | +======================+=====================================================================================================+ 279 | | ``'SIZE'`` | ```` - image's file size should be equal to this number | 280 | | | ```` - : ```` | 281 | +----------------------+-----------------------------------------------------------------------------------------------------+ 282 | | ``'DIMENSIONS'`` | ```` - a two-length tuple of positive integers | 283 | | | | 284 | | | ```` - a list of two-length tuples of positive integers | 285 | | | | 286 | | | ```` - : ```` | 287 | | | | 288 | | | ```` - ``'w'`` and/or ``'h'``: ```` - : ```` | 289 | | | | 290 | +----------------------+-----------------------------------------------------------------------------------------------------+ 291 | | ``'FORMAT'`` | ```` - one of ``'jpeg'``, ``'png'``, ``'gif'``, ``'bmp'``, ``'webp'`` | 292 | | | | 293 | | | ```` - a list with one or more of the valid formats | 294 | | | | 295 | | | ```` - ``'ne'`` or ``'eq'``: ```` (one of the valid formats) | 296 | | | | 297 | | | ```` - ``'ne'`` or ``'eq'``: ```` (a list with one or more of the valid formats) | 298 | | | | 299 | +----------------------+-----------------------------------------------------------------------------------------------------+ 300 | | ``'ASPECT_RATIO'`` | ```` - a float number | 301 | | | | 302 | | | ```` - : ```` | 303 | | | | 304 | +----------------------+-----------------------------------------------------------------------------------------------------+ 305 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/source/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ======== 5 | 6 | The following examples try to cover a variety of |config_name| :ref:`usages ` by combining 7 | different specificity values and showing their effect on the appropriate fields. 8 | 9 | 10 | With specificity 1 11 | ------------------ 12 | 13 | The validation rule below will be applied to all ``ImageField``'s of the ``my_app`` app. 14 | Each image should be *less than 120KB* **and** *have aspect ratio equal to 1.2*. 15 | :: 16 | 17 | VIMAGE = { 18 | 'my_app.models': { 19 | 'SIZE': { 20 | 'lt': 120, 21 | } 22 | 'ASPECT_RATIO': 1.2, 23 | }, 24 | } 25 | 26 | If we try to upload an image which is more than 120KB and it's aspect ratio is not 1.2 then we'll get the following default error: 27 | 28 | .. image:: _static/specificity_1_default_error.png 29 | :alt: size and aspect ratio default error 30 | 31 | Since ``'SIZE'`` is a ``dict`` we can define an ``'err'`` key and give it a :ref:`custom error `: 32 | :: 33 | 34 | VIMAGE = { 35 | 'my_app.models': { 36 | 'SIZE': { 37 | 'lt': 120, 38 | 'err': 'Wrong size. Must be < 120KB', 39 | } 40 | 'ASPECT_RATIO': 1.2, 41 | }, 42 | } 43 | 44 | which yields: 45 | 46 | .. image:: _static/specificity_1_custom_error.png 47 | :alt: size and aspect ratio custom error 48 | 49 | 50 | With specificity 2 51 | ------------------ 52 | 53 | The validation rule below will be applied to all ``ImageField``'s of the ``MyModel`` model. 54 | Each image should be *a JPEG image*, *equal to 400 x 500px* **and** *less than 200KB*. 55 | :: 56 | 57 | VIMAGE = { 58 | 'my_app.models.MyModel': { 59 | 'FORMAT': 'jpeg', 60 | 'DIMENSIONS': (400, 500), 61 | 'SIZE': { 62 | 'lt': 200, 63 | }, 64 | }, 65 | } 66 | 67 | With specificity 3 68 | ------------------ 69 | 70 | The validation rule below will be applied only to the ``img`` ``ImageField`` field. 71 | It should *be a JPEG or a PNG image*, *the height should be less than 400px* **and** *be greater than 100KB but less than 200KB*. 72 | :: 73 | 74 | VIMAGE = { 75 | 'my_app.models.MyModel.img': { 76 | 'FORMAT': ['jpeg', 'png'], 77 | 'DIMENSIONS': { 78 | 'h': { 79 | 'lt': 400, 80 | } 81 | }, 82 | 'SIZE': { 83 | 'gt': 100, 84 | 'lt': 200, 85 | }, 86 | }, 87 | } 88 | 89 | Trying to save the object with an *invalid* image, we get the following default error: 90 | 91 | .. image:: _static/specificity_3_default_error.png 92 | :alt: default error with image height and image file size 93 | 94 | 95 | A custom error on ``'h'`` (height) may be declared, as follows: 96 | :: 97 | 98 | VIMAGE = { 99 | 'my_app.models.MyModel': { 100 | 'FORMAT': ['jpeg', 'png'], 101 | 'DIMENSIONS': { 102 | 'h': { 103 | 'lt': 400, 104 | 'err': 'Height must be >400px', 105 | } 106 | }, 107 | 'SIZE': { 108 | 'gt': 100, 109 | 'lt': 200, 110 | }, 111 | }, 112 | } 113 | 114 | Trying with an *invalid* image, we get (note that we have provided a valid image format, so the ``'FORMAT'`` validation passes and not shown): 115 | 116 | .. image:: _static/specificity_3_custom_error.png 117 | :alt: custom error with image height and image file size 118 | 119 | 120 | With specificity 1 + 2 121 | ---------------------- 122 | 123 | :: 124 | 125 | VIMAGE = { 126 | # specificity 1 127 | 'my_app.models': { 128 | 'FORMAT': ['jpeg', 'png'], 129 | 'SIZE': { 130 | 'gt': 100, 131 | 'lt': 200, 132 | }, 133 | }, 134 | # specificity 2 135 | 'my_app.models.ModelOne': { 136 | 'DIMENSIONS': [(400, 450), (500, 650)], 137 | }, 138 | # specificity 2 139 | 'my_app.models.ModelTwo': { 140 | 'FORMAT': ['webp'], 141 | }, 142 | } 143 | 144 | After declaring the above validation rule, the following rules will apply: 145 | 146 | +----------------------------------------------+-------------------------------------------------------+ 147 | | all ``ImageField``'s of the | Rules | 148 | +==============================================+=======================================================+ 149 | | | - ``'FORMAT': ['jpeg', 'png']`` | 150 | | ``ModelOne`` model | - ``'SIZE': {'gt': 100, 'lt': 200}`` | 151 | | | - ``'DIMENSIONS': [(400, 450), (500, 650)]`` | 152 | +----------------------------------------------+-------------------------------------------------------+ 153 | | ``ModelTwo`` model | - ``'FORMAT': ['webp']`` | 154 | | | - ``'SIZE': {'gt': 100, 'lt': 200}`` | 155 | +----------------------------------------------+-------------------------------------------------------+ 156 | 157 | and providing (again) an *invalid* image, we get the following default error for the ``img`` ``ImageField`` inside the ``ModelOne`` model: 158 | 159 | .. image:: _static/specificity_1_2_default_error.png 160 | :alt: custom error with image format, image size and dimensions 161 | 162 | 163 | With specificity 1 + 3 164 | ---------------------- 165 | 166 | :: 167 | 168 | VIMAGE = { 169 | # specificity 1 170 | 'my_app.models': { 171 | 'DIMENSIONS': { 172 | 'lte': (1920, 1080), 173 | }, 174 | 'FORMAT': 'jpeg', 175 | 'SIZE': { 176 | 'gt': 100, 177 | 'lt': 200, 178 | }, 179 | }, 180 | # specificity 3 181 | 'my_app.models.ModelOne.img': { 182 | 'DIMENSIONS': (800, 1020), 183 | }, 184 | } 185 | 186 | After declaring the above validation rule, the following rules will apply: 187 | 188 | +---------------------------------------------------+-------------------------------------------------------+ 189 | | Fields | Rules | 190 | +===================================================+=======================================================+ 191 | | all ``ImageField``'s of the ``my_app`` app | - ``'DIMENSIONS': {'lte': (1920, 1080)}`` | 192 | | | - ``'FORMAT': 'jpeg'`` | 193 | | | - ``'SIZE': {'gt': 100, 'lt': 200}`` | 194 | +---------------------------------------------------+-------------------------------------------------------+ 195 | | only the ``img`` field | - ``'DIMENSIONS': (800, 1020)`` | 196 | | | - ``'FORMAT': 'jpeg'`` | 197 | | | - ``'SIZE': {'gt': 100, 'lt': 200}`` | 198 | +---------------------------------------------------+-------------------------------------------------------+ 199 | -------------------------------------------------------------------------------- /docs/source/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/source/how_it_works.rst: -------------------------------------------------------------------------------- 1 | .. _how-it-works: 2 | 3 | How it works 4 | ============ 5 | 6 | The mechanism, under the hood, of the |config_name| is pretty simple. 7 | 8 | In a glance it does the following: 9 | 10 | - *converts* each rule dict (i.e ``{'SIZE': 100}`` etc) to a corresponding ``class``. 11 | - each ``class`` defines a method which returns a function (callable, the validator) 12 | - a registry is build which has the ``ImageField`` as keys and a list of functions (validators) as the value 13 | - finally, each validator is added to the ``ImageField``'s ``validators`` attribute (a method defined as a ``cached_property``) 14 | 15 | .. image:: _static/code_structure.png 16 | :alt: code structure 17 | :width: 400px 18 | :align: center 19 | 20 | For more info about Model ``validators`` refer to `validators `_. 21 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-vimage documentation master file, created by 2 | sphinx-quickstart on Mon Apr 9 13:05:19 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-vimage's documentation! 7 | ========================================= 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | 12 | readme 13 | installation 14 | usage 15 | configuration 16 | how_it_works 17 | examples 18 | reference 19 | contributing 20 | authors 21 | history 22 | 23 | 24 | Indices and tables 25 | ================== 26 | 27 | - :ref:`genindex` 28 | - :ref:`modindex` 29 | - :ref:`search` 30 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | You should **always** use a `virtualenv `_ 5 | or the recommended Python way, `pipenv `_. Whichever works best for you. Once you're inside your preferred virtual environment, run:: 6 | 7 | (venv-name)$ pip install django-vimage 8 | 9 | -------------------------------------------------------------------------------- /docs/source/readme.rst: -------------------------------------------------------------------------------- 1 | .. _readme: 2 | 3 | Introduction 4 | ============ 5 | 6 | .. image:: https://img.shields.io/pypi/v/django-vimage.svg?style=flat-square 7 | :target: https://pypi.org/project/django-vimage/ 8 | :alt: Latest PyPI version badge 9 | 10 | .. image:: https://img.shields.io/travis/manikos/django-vimage/master.svg?style=flat-square 11 | :target: https://travis-ci.org/manikos/django-vimage 12 | :alt: Travis CI build status badge 13 | 14 | .. image:: https://img.shields.io/codecov/c/github/manikos/django-vimage.svg?style=flat-square 15 | :target: https://codecov.io/gh/manikos/django-vimage 16 | :alt: Codecov status badge 17 | 18 | .. image:: https://img.shields.io/readthedocs/django-vimage.svg?style=flat-square 19 | :target: https://readthedocs.org/projects/django-vimage/ 20 | :alt: ReadTheDocs documentation status badge 21 | 22 | .. image:: https://img.shields.io/pypi/pyversions/django-vimage.svg?style=flat-square 23 | :target: https://pypi.org/project/django-vimage/ 24 | :alt: Supported python versions badge 25 | 26 | .. image:: https://img.shields.io/pypi/djversions/django-vimage.svg?style=flat-square 27 | :target: https://pypi.org/project/django-vimage/ 28 | :alt: Supported Django versions badge 29 | 30 | .. image:: https://img.shields.io/github/license/manikos/django-vimage.svg?style=flat-square 31 | :target: https://github.com/manikos/django-vimage/bolb/master/LICENSE 32 | :alt: License badge 33 | 34 | Django Image validation for the `Django Admin `_ as a breeze. Validations on: Size, Dimensions, Format and Aspect Ratio. 35 | 36 | Because, I love to look for the origin of a word/band/place/something, this package name comes from the word *validate* and (you guessed it) *image*. Thus, ``django-vimage``. 37 | Nothing more, nothing less :) 38 | 39 | This package was created due to lack of similar Django packages that do image validation. I searched for this but found nothing. So, I decided to create a reusable Django package 40 | that will do image validation in a simple manner. Just declare some ``ImageField``\s and the rules to apply to them in a simple Python dictionary. Firstly, I wrote the blueprint 41 | on a piece of paper and then I, gradually, ported it to Django/Python code. 42 | 43 | 44 | Quickstart 45 | ---------- 46 | 47 | Install django-vimage 48 | :: 49 | 50 | pip install django-vimage 51 | 52 | Add it to your ``INSTALLED_APPS`` 53 | :: 54 | 55 | INSTALLED_APPS = ( 56 | ... 57 | 'vimage.apps.VimageConfig', 58 | ... 59 | ) 60 | 61 | 62 | Finally, add the |config_name| dict configuration somewhere in your ``settings`` file 63 | :: 64 | 65 | VIMAGE = { 66 | 'my_app.models': { 67 | 'DIMENSIONS': (200, 200), 68 | 'SIZE': {'lt': 100}, 69 | } 70 | } 71 | 72 | The above |config_name| setting sets the rules for all Django ``ImageField`` fields under the ``my_app`` app. 73 | More particular, all ``ImageField``\s should be 200 x 200px **and** less than 100KB. Any image than violates 74 | any of the above rules, a nice-looking error message will be shown (translated accordingly) in the Django admin page. 75 | 76 | A full example of possible key:value pairs is shown below. Note that the following code block is not suitable for copy-paste into your 77 | ``settings`` file since it contains duplicate dict keys. It's just for demonstration. For valid examples, refer to the :ref:`examples `. 78 | 79 | .. code-block:: python 80 | :name: vimage_example_definition 81 | 82 | VIMAGE = { 83 | # Possible keys are: 84 | # 'app.models' # to all ImageFields inside this app 85 | # 'app.models.MyModel' # to all ImageFields inside MyModel 86 | # 'app.models.MyModel.field' # only to this ImageField 87 | 88 | # Example of applying validation rules to all images across 89 | # all models of myapp app 90 | 'myapp.models': { 91 | # rules 92 | }, 93 | 94 | # Example of applying validation rules to all images across 95 | # a specific model 96 | 'myapp.models.MyModel': { 97 | # rules 98 | }, 99 | 100 | # Example of applying validation rules to a 101 | # specific ImageField field 102 | 'myapp.models.MyModel.img': { 103 | # rules 104 | }, 105 | 106 | # RULES 107 | 'myapp.models': { 108 | 109 | # By size (measured in KB) 110 | 111 | # Should equal to 100KB 112 | 'SIZE': 100, # defaults to eq (==) 113 | 114 | # (100KB <= image_size <= 200KB) AND not equal to 150KB 115 | 'SIZE': { 116 | 'gte': 100, 117 | 'lte': 200, 118 | 'ne': 150, 119 | }, 120 | 121 | # Custom error message 122 | 'SIZE': { 123 | 'gte': 100, 124 | 'lte': 200, 125 | 'err': 'Your own error message instead of the default.' 126 | 'Supports html tags too!', 127 | }, 128 | 129 | 130 | # By dimensions (measured in px) 131 | # Should equal to 1200x700px (width x height) 132 | 'DIMENSIONS': (1200, 700), # defaults to eq (==) 133 | 134 | # Should equal to one of these sizes 1000x300px or 1500x350px 135 | 'DIMENSIONS': [(1000, 300), (1500, 350)], 136 | 137 | # Should be 1000x300 <= image_dimensions <= 2000x500px 138 | 'DIMENSIONS': { 139 | 'gte': (1000, 300), 140 | 'lte': (2000, 500), 141 | }, 142 | 143 | # width must be >= 30px and less than 60px 144 | # height must be less than 90px and not equal to 40px 145 | 'DIMENSIONS': { 146 | 'w': { 147 | 'gt': 30, 148 | 'lt': 60, 149 | }, 150 | 'h': { 151 | 'lt': 90, 152 | 'ne': 40, 153 | } 154 | }, 155 | 156 | 157 | # By format (jpeg, png, tiff etc) 158 | # Uploaded image should be JPEG 159 | 'FORMAT': 'jpeg', 160 | 161 | # Uploaded image should be one of the following 162 | 'FORMAT': ['jpeg', 'png', 'gif'], 163 | 164 | # Uploaded image should not be a GIF 165 | 'FORMAT': { 166 | 'ne': 'gif', 167 | }, 168 | 169 | # Uploaded image should be neither a GIF nor a PNG 170 | 'FORMAT': { 171 | 'ne': ['gif', 'png'], 172 | 'err': 'Wrong image format!' 173 | }, 174 | } 175 | } 176 | 177 | 178 | Features 179 | -------- 180 | 181 | - An image may be validated against its :ref:`size (KB) `, :ref:`dimensions (px) `, 182 | :ref:`format (jpeg, png etc) ` and :ref:`aspect ratio (width/height ratio) `. 183 | 184 | - :ref:`Well formatted error messages `. They have the form of: 185 | 186 | **[IMAGE RULE_NAME]** Validation error: **image_value** does not meet validation rule: **rule**. 187 | 188 | - Humanized error messages. All rules and image values are *humanized*: 189 | 190 | - ``'SIZE': {'gte': 100}`` becomes ``greater than or equal to 100KB`` when rendered 191 | 192 | - ``'DIMENSIONS': {'ne': (100, 100)}`` becomes ``not equal to 100 x 100px`` when rendered 193 | 194 | - :ref:`Overridable error messages `. The default error messages may be overridden by defining an ``err`` key inside the validation rules: 195 | 196 | ``'SIZE': {'gte': 100, 'err': 'Custom error'}`` becomes ``Custom error`` when rendered 197 | 198 | - :ref:`HTML-safe (custom) error messages `. All error messages (the default or your own) are passed through the 199 | function :func:`~django.utils.safestring.mark_safe`. 200 | 201 | - Cascading validation rules. It's possible to define a generic rule to some ``ImageField`` fields of an app and 202 | then define another set of rules to a specific ``ImageField`` field. 203 | Common rules will override the generic ones and any new rules will be added to the specific ``ImageField`` field 204 | :: 205 | 206 | myapp.models: { 207 | 'SIZE': { 208 | 'lt': 120, 209 | }, 210 | 'FORMAT': 'jpeg', 211 | 'DIMENSIONS': { 212 | 'lt': (500, 600), 213 | } 214 | }, 215 | myapp.models.MyModel.img: { 216 | 'DIMENSIONS': (1000, 500), 217 | }, 218 | 219 | In the example above (the order does not matter), all ``ImageField``\s should be 220 | ``less than 120KB``, ``JPEG`` images **and** ``less than 500 x 600px``. 221 | However, the ``myapp.models.MyModel.img`` field should be ``less than 120KB``, 222 | ``JPEG`` image **and** ``equal to 1000 x 500px``. 223 | 224 | 225 | Running Tests 226 | ------------- 227 | 228 | Does the code actually work? 229 | :: 230 | 231 | source /bin/activate 232 | (myenv) $ pip install tox 233 | (myenv) $ tox 234 | 235 | 236 | Future additions 237 | ---------------- 238 | 239 | - Validation of image mode (whether the uploaded image is in indexed mode, greyscale mode etc) based on `image's mode `_. 240 | This is quite easy to implement but rather a *rare* validation requirement. Thus, it'll be implemented if users want to validate the mode of the image (which again, it's rare for the web). 241 | 242 | - If you think of any other validation (apart from svg) that may be applied to an image and it's not included in this package, please feel free to submit an issue or a PR. 243 | 244 | Credits 245 | ------- 246 | 247 | Tools used in rendering this package: 248 | 249 | - `Cookiecutter `_ 250 | - `cookiecutter-djangopackage `_ 251 | -------------------------------------------------------------------------------- /docs/source/reference.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | The following sections show some modules of this package, in alphabetical order, just for reference. 5 | It's **not** a public API to use. The developer, should only set the :ref:`VIMAGE ` dictionary. 6 | 7 | .. module:: vimage.core 8 | 9 | 10 | :mod:`vimage.core.base` 11 | ------------------------------ 12 | 13 | .. automodule:: vimage.core.base 14 | :members: 15 | :undoc-members: 16 | 17 | 18 | :mod:`vimage.core.checker` 19 | --------------------------------- 20 | 21 | .. automodule:: vimage.core.checker 22 | :members: 23 | :undoc-members: 24 | 25 | 26 | :mod:`vimage.core.const` 27 | ------------------------------- 28 | 29 | .. automodule:: vimage.core.const 30 | :members: 31 | :undoc-members: 32 | 33 | 34 | :mod:`vimage.core.validator_types` 35 | ----------------------------------------- 36 | 37 | .. automodule:: vimage.core.validator_types 38 | :members: 39 | :undoc-members: 40 | -------------------------------------------------------------------------------- /docs/source/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ===== 5 | 6 | To use django-vimage in a project, add it to your ``INSTALLED_APPS`` 7 | :: 8 | 9 | INSTALLED_APPS = ( 10 | ... 11 | 'vimage.apps.VimageConfig', 12 | ... 13 | ) 14 | 15 | And then define a |config_name| dictionary with the appropriate key:value pairs. 16 | Every :ref:`key ` should be a ``str`` while every :ref:`value ` should be a ``dict``. 17 | :: 18 | 19 | VIMAGE = { 20 | # key:value pairs 21 | } 22 | 23 | 24 | .. _vimage-key: 25 | 26 | |config_name| key 27 | ----------------- 28 | 29 | Each |config_name| key should be a ``str``, the dotted path to one of the following: 30 | 31 | - :mod:`~django.db.models` module (i.e ``myapp.models``). This is the global setting. The rule will apply to all ``ImageField`` fields defined in this ``models`` module. 32 | 33 | - Django :class:`~django.db.models.Model` (i.e ``myapp.models.MyModel``). This is a model-specific setting. The rule will apply to all ``ImageField`` fields defined under this model. 34 | 35 | - Django :class:`~django.db.models.ImageField` field (i.e ``myapp.models.MyModel.img``). This is a field-specific setting. The rule will apply to just this ``ImageField``. 36 | 37 | It is allowed to have multiple keys refer to the same app. Keep in mind, though, that 38 | keys referring to specific ``ImageField``'s have higher precedence to those referring to 39 | a specific ``Model`` and any common rules will be overridden while new ones will be added. 40 | 41 | For example, suppose you have a project structure like the following: 42 | :: 43 | 44 | my_project/ 45 | my_app/ 46 | models.py 47 | views.py 48 | my_project/ 49 | settings.py 50 | urls.py 51 | manage.py 52 | 53 | and ``my_app.models`` defines the following models: 54 | :: 55 | 56 | from django.db import models 57 | 58 | class Planet(models.Model): 59 | # ... other model fields here 60 | large_photo = models.ImageField(upload_to='planets') 61 | small_photo = models.ImageField(upload_to='planets') 62 | 63 | class Satellite(models.Model): 64 | # ... other model fields here 65 | outer_photo = models.ImageField(upload_to='satellite') 66 | inner_photo = models.ImageField(upload_to='satellite') 67 | 68 | and the keys defined are the following: 69 | :: 70 | 71 | VIMAGE = { 72 | 'my_app.models': {# rules here}, 73 | 'my_app.models.Planet': {# rules here}, 74 | 'my_app.models.Satellite': {# rules here}, 75 | 'my_app.models.Satellite.inner_photo': {# rules here}, 76 | } 77 | 78 | Then, all ``ImageField``'s of ``my_app`` app (``large_photo``, ``small_photo``, ``outer_photo`` and ``inner_photo``) 79 | will have the rules defined in ``my_app.models`` dict value. 80 | However, the rules defined in ``my_app.models.Planet`` (affecting ``ImageField``'s of the ``Planet`` model) will override 81 | the previous ones and any new will be added. The same principle applies to the ``Satellite`` ``ImageField``'s. 82 | 83 | In general, rules have specificity, just like CSS. This is a good thing because you can apply some rules globally and then 84 | become more particular on a per ``ImageField`` level. 85 | 86 | The specificity is shown below: 87 | 88 | +-----------------------------------------+-------------+ 89 | | Key | Specificity | 90 | +=========================================+=============+ 91 | | ``.models`` | 1 | 92 | +-----------------------------------------+-------------+ 93 | | ``.models.`` | 2 | 94 | +-----------------------------------------+-------------+ 95 | | ``.models..`` | 3 | 96 | +-----------------------------------------+-------------+ 97 | 98 | The higher the specificity, the higher the precedence of the rule. 99 | 100 | 101 | .. _vimage-value: 102 | 103 | |config_name| value 104 | ------------------- 105 | 106 | Each |config_name| value should be a dictionary. The structure must be: 107 | :: 108 | 109 | { 110 | '': , 111 | } 112 | 113 | Each key of the dictionary should be one of the following validation strings: 114 | 115 | - :ref:`'SIZE' `, image file size 116 | - :ref:`'DIMENSIONS' `, image dimensions 117 | - :ref:`'FORMAT' `, image format (i.e JPEG, PNG etc) 118 | - :ref:`'ASPECT_RATIO' ` image width / image height ratio 119 | 120 | Depending on the validation string, the corresponding value type (and unit) varies. The table below 121 | shows the valid key:value pair types: 122 | 123 | +---------------------------------------+-------------------------------------------+----------------------+ 124 | | Key (always ``str``) | Value type | Unit | 125 | +=======================================+===========================================+======================+ 126 | | ``'SIZE'`` | ```` | ```` | ``KB`` | 127 | +---------------------------------------+-------------------------------------------+----------------------+ 128 | | ``'DIMENSIONS'`` | ```` | ```` | ```` | ``px`` | 129 | +---------------------------------------+-------------------------------------------+----------------------+ 130 | | ``'FORMAT'`` | ```` | ```` | ```` | no unit | 131 | +---------------------------------------+-------------------------------------------+----------------------+ 132 | | ``'ASPECT_RATIO'`` | ```` | ```` | no unit | 133 | +---------------------------------------+-------------------------------------------+----------------------+ 134 | 135 | For example, the following (full example) rule states that the uploaded image (via the Django Admin) must be, for some reason, equal to 100KB: 136 | :: 137 | 138 | VIMAGE = { 139 | 'my_app.models.MyModel.img': { 140 | 'SIZE': 100, 141 | } 142 | } 143 | 144 | The following rule states that the uploaded image must be either a JPEG or a PNG format: 145 | :: 146 | 147 | VIMAGE = { 148 | 'my_app.models.MyModel.img': { 149 | 'FORMAT': ['jpeg', 'png'], 150 | } 151 | } 152 | 153 | When the value is a dict, |config_name| uses the :py:mod:`operator` module to apply the rules. 154 | All keys accept the ```` value type with the following strings as keys: 155 | 156 | .. code-block:: bash 157 | :caption: valid operator strings 158 | :name: operator_strings 159 | 160 | +-------------------------+-----------------------------+ 161 | | Operator string | Meaning | 162 | +=========================+=============================+ 163 | | 'gte' | greater than or equal to | 164 | +-------------------------+-----------------------------+ 165 | | 'lte' | less than or equal to | 166 | +-------------------------+-----------------------------+ 167 | | 'gt' | greater than | 168 | +-------------------------+-----------------------------+ 169 | | 'lt' | less than | 170 | +-------------------------+-----------------------------+ 171 | | 'eq' | equal to | 172 | +-------------------------+-----------------------------+ 173 | | 'ne' | not equal to | 174 | +-------------------------+-----------------------------+ 175 | 176 | However, the ``'FORMAT'`` validation rule accepts a *minimal* set of operators that may be applied only to string values (not numbers). 177 | That is, ``'eq'`` and ``'ne'``. 178 | 179 | .. note:: Keep in mind that an error is raised if some specific operator pairs are used, for example, ``'gte'`` and ``'eq'``. 180 | This is because it makes no sense for an image to be ``greater than or equal to something`` and at the same time ``equal to something``! 181 | :name: operator_strings_note 182 | 183 | Confused? Take a look at the :ref:`examples `. 184 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/requirements.txt -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bumpversion==0.6.0 2 | Django>=2 3 | Pillow==9.1.1 4 | sphinx==5.0.1 5 | sphinx-autobuild==2021.3.14 6 | twine==4.0.1 7 | wheel==0.37.1 -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage==6.2 2 | flake8==4.0.1 3 | tox==3.25.0 4 | codecov==2.1.12 5 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | import django 7 | from django.conf import settings 8 | from django.test.utils import get_runner 9 | 10 | 11 | def run_tests(*test_args): 12 | if not test_args: 13 | test_args = ['tests'] 14 | else: 15 | test_args = [f'tests.test_suites.{test_args[0]}'] 16 | 17 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 18 | django.setup() 19 | TestRunner = get_runner(settings) 20 | test_runner = TestRunner() 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(bool(failures)) 23 | 24 | 25 | if __name__ == '__main__': 26 | run_tests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.2.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:setup.py] 8 | 9 | [bumpversion:file:vimage/__init__.py] 10 | 11 | [flake8] 12 | ignore = D203 13 | exclude = 14 | .git, 15 | .tox, 16 | docs/conf.py, 17 | build, 18 | dist 19 | max-line-length = 119 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | 8 | 9 | def get_version(*file_paths): 10 | """Retrieves the version from vimage/__init__.py""" 11 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 12 | version_file = open(filename).read() 13 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 14 | version_file, re.M) 15 | if version_match: 16 | return version_match.group(1) 17 | raise RuntimeError('Unable to find version string.') 18 | 19 | 20 | version = get_version("vimage", "__init__.py") 21 | 22 | if sys.argv[-1] == 'pypi': 23 | try: 24 | import twine 25 | print(f'Twine version: {twine.__version__}') 26 | except ImportError: 27 | print('"twine" library is missing. Please run "pip install twine"') 28 | sys.exit() 29 | test = sys.argv[-2] == '--test' 30 | repo_url = '--repository-url https://test.pypi.org/legacy/' if test else '' 31 | # upon new release, create new source distribution and a 32 | # new wheel distribution 33 | print(f'[Step 1/3] creating source and wheel distributions ' 34 | f'for version {version}{"." * 10}') 35 | os.system('python setup.py sdist bdist_wheel') 36 | print('[Step 1/3] DONE!') 37 | # # upon creation of the above two files, upload to pypi.org only 38 | # # the newest version. First the source distribution 39 | print(f'[Step 2/3] uploading source distribution{"." * 10}') 40 | os.system(f'twine upload {repo_url} dist/django-vimage-{version}.tar.gz') 41 | print('[Step 2/3] DONE!') 42 | # # and then the wheel one. 43 | print(f'[Step 3/3] uploading wheel distribution to pypi.org{"." * 10}') 44 | os.system(f'twine upload {repo_url} ' 45 | f'dist/django_vimage-{version}-py3-none-any.whl') 46 | print('[Step 3/3] DONE!') 47 | print("Everything's done, congratulations!") 48 | sys.exit() 49 | 50 | readme = open('README.md').read() 51 | 52 | setup( 53 | name='django-vimage', 54 | packages=find_packages( 55 | include=('vimage', 'vimage.*'), 56 | ), 57 | version='1.2.0', 58 | description=""" 59 | Image validation (for the Django Admin) as a breeze. 60 | Validations on: Size, Dimensions, Format and Aspect Ratio. 61 | """, 62 | long_description=readme, 63 | long_description_content_type='text/markdown', 64 | author='Nick Mavrakis', 65 | author_email='mavrakis.n@gmail.com', 66 | url='https://github.com/manikos/django-vimage', 67 | include_package_data=True, # anything referred under MANIFEST.in 68 | python_requires='>=3.6', 69 | install_requires=['Django>=2', 'Pillow>=4.0.0'], 70 | license='MIT', 71 | zip_safe=False, 72 | keywords='django image-validation django-admin easy-to-use', 73 | classifiers=[ 74 | 'Development Status :: 5 - Production/Stable', 75 | 'Environment :: Web Environment', 76 | 'Framework :: Django', 77 | 'Framework :: Django :: 2.0', 78 | 'Framework :: Django :: 3.0', 79 | 'Intended Audience :: Developers', 80 | 'License :: OSI Approved :: MIT License', 81 | 'Operating System :: OS Independent', 82 | 'Natural Language :: English', 83 | 'Programming Language :: Python :: 3', 84 | 'Programming Language :: Python :: 3.6', 85 | 'Topic :: Internet :: WWW/HTTP', 86 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 87 | ], 88 | ) 89 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/__init__.py -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/apps/__init__.py -------------------------------------------------------------------------------- /tests/apps/myapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/apps/myapp/__init__.py -------------------------------------------------------------------------------- /tests/apps/myapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MyAppConfig(AppConfig): 5 | name = "tests.apps.myapp" 6 | default_auto_field = "django.db.models.BigAutoField" 7 | -------------------------------------------------------------------------------- /tests/apps/myapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class MyModel(models.Model): 5 | heading = models.CharField(max_length=5) 6 | img = models.ImageField(upload_to='my_model/img') 7 | number = models.PositiveSmallIntegerField() 8 | 9 | def __str__(self): 10 | return self.heading 11 | 12 | 13 | class AnotherModel(models.Model): 14 | title = models.CharField(max_length=10) 15 | thumb = models.ImageField(upload_to='another_model/thumb') 16 | image = models.ImageField(upload_to='another_model/image') 17 | 18 | def __str__(self): 19 | return self.title 20 | 21 | 22 | class GreatModel(models.Model): 23 | name = models.CharField(max_length=10) 24 | picture = models.ImageField(upload_to='great_model/picture') 25 | large_img = models.ImageField(upload_to='great_model/large_img') 26 | 27 | def __str__(self): 28 | return self.name 29 | -------------------------------------------------------------------------------- /tests/apps/myapp2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/apps/myapp2/__init__.py -------------------------------------------------------------------------------- /tests/apps/myapp2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MyApp2Config(AppConfig): 5 | name = "tests.apps.myapp2" 6 | default_auto_field = "django.db.models.BigAutoField" 7 | -------------------------------------------------------------------------------- /tests/apps/myapp2/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import Hello 2 | -------------------------------------------------------------------------------- /tests/apps/myapp2/models/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Hello(models.Model): 5 | name = models.CharField(max_length=10) 6 | img = models.ImageField() 7 | -------------------------------------------------------------------------------- /tests/apps/no_model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/apps/no_model/__init__.py -------------------------------------------------------------------------------- /tests/apps/no_model/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NoModelConfig(AppConfig): 5 | name = "tests.apps.no_model" 6 | default_auto_field = "django.db.models.BigAutoField" 7 | -------------------------------------------------------------------------------- /tests/images/500x498-100KB.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/images/500x498-100KB.jpeg -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=2 2 | Pillow>=4.0.0 3 | -e django-vimage 4 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | DEBUG = True 7 | 8 | SECRET_KEY = 'fake-key' 9 | 10 | 11 | INSTALLED_APPS = [ 12 | 'django.contrib.contenttypes', 13 | 'django.contrib.sessions', 14 | 'django.contrib.messages', 15 | 'django.contrib.staticfiles', 16 | 'vimage', 17 | 'tests', 18 | # Dummy apps for testing 19 | 'tests.apps.myapp', 20 | 'tests.apps.myapp2', 21 | 'tests.apps.no_model', 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 28 | 'django.contrib.messages.middleware.MessageMiddleware', 29 | ] 30 | 31 | 32 | DATABASES = { 33 | 'default': { 34 | 'ENGINE': 'django.db.backends.sqlite3', 35 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 36 | } 37 | } 38 | 39 | VIMAGE = { 40 | 'myapp.models': { 41 | 'SIZE': 100, 42 | }, 43 | 'myapp2.models.Hello.img': { 44 | 'SIZE': 200, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /tests/test_suites/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/tests/test_suites/__init__.py -------------------------------------------------------------------------------- /tests/test_suites/const.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.files.uploadedfile import SimpleUploadedFile 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | ROOT = 'vimage' 9 | CORE = f'{ROOT}.core' 10 | IMAGES_PATH = os.path.join(BASE_DIR, 'images') 11 | 12 | 13 | def dotted_path(module, class_name, method_name): 14 | keywords = [CORE, module, class_name, method_name] 15 | return f'{".".join(keywords)}' 16 | 17 | 18 | def image(name, path): 19 | return SimpleUploadedFile( 20 | name=name, 21 | content=open(path, 'rb').read(), 22 | content_type='image/jpeg' 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_suites/test_checker.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.conf import settings 4 | from django.test import TestCase, override_settings 5 | 6 | from vimage.core import exceptions 7 | from vimage.core.const import CONFIG_NAME, APP_NAME 8 | from vimage.core.checker import configuration_check 9 | 10 | from .const import dotted_path 11 | 12 | 13 | class ConfigurationTestCase(TestCase): 14 | @override_settings() 15 | def test_missing_config(self): 16 | del settings.VIMAGE 17 | error = f'"{APP_NAME}" is in INSTALLED_APPS but has not been ' \ 18 | f'configured! Either add "{CONFIG_NAME}" dict to your ' \ 19 | f'settings or remove "{APP_NAME}" from INSTALLED_APPS.' 20 | with self.assertRaisesMessage(exceptions.MissingConfigError, error): 21 | configuration_check() 22 | 23 | @override_settings() 24 | def test_invalid_config_type(self): 25 | invalid_config_types = [int, float, complex, list, tuple, 26 | str, bytes, bytearray, set, frozenset] 27 | for invalid_type in invalid_config_types: 28 | with self.subTest(invalid_type=invalid_type): 29 | del settings.VIMAGE 30 | settings.VIMAGE = invalid_type 31 | error = f'"{CONFIG_NAME}" type is not a dictionary. ' \ 32 | f'The value should be a non-empty dict!' 33 | with self.assertRaisesMessage( 34 | exceptions.InvalidConfigValueError, error 35 | ): 36 | configuration_check() 37 | 38 | def test_empty_config(self): 39 | with self.settings(VIMAGE={}): 40 | error = f'"{CONFIG_NAME}" configuration is an empty dict! ' \ 41 | f'Add validation rules inside the "{CONFIG_NAME}" dict.' 42 | with self.assertRaisesMessage(exceptions.EmptyConfigError, error): 43 | configuration_check() 44 | 45 | def test_vimage_entry_is_valid_called(self): 46 | with self.settings(VIMAGE={'my_app': {'SIZE': 100}}): 47 | with patch(dotted_path('base', 'VimageEntry', 'is_valid')) as m: 48 | configuration_check() 49 | self.assertTrue(m.called) 50 | -------------------------------------------------------------------------------- /tests/test_suites/test_core_init.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.conf import settings 4 | from django.test import TestCase, override_settings 5 | 6 | from vimage.core import add_validators 7 | from .const import dotted_path 8 | 9 | 10 | class CoreInitTestCase(TestCase): 11 | @override_settings() 12 | def test_add_validators(self): 13 | settings.VIMAGE = 'VIMAGE' 14 | with patch(dotted_path('base', 'VimageConfig', 'add_validators')) as m: 15 | add_validators() 16 | self.assertTrue(m.called) 17 | -------------------------------------------------------------------------------- /tests/test_suites/test_validation_rule_aspect_ratio.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from django.core.exceptions import ValidationError 6 | 7 | from vimage.core.validator_types import ValidationRuleAspectRatio 8 | from vimage.core.exceptions import InvalidValueError 9 | 10 | from .test_validation_rule_base import ValidatorTestCase 11 | from .const import dotted_path 12 | 13 | 14 | class ValidationRuleAspectRatioTestCase(TestCase): 15 | def test_humanize_rule(self): 16 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', 1.5) 17 | self.assertEqual(vr.humanize_rule(), 'equal to 1.5') 18 | 19 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', {'eq': 1.5}) 20 | self.assertEqual(vr.humanize_rule(), 'equal to 1.5') 21 | 22 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', {'gt': 1.5, 'lt': 2.1}) 23 | self.assertEqual( 24 | vr.humanize_rule(), 25 | 'greater than 1.5 and less than 2.1' 26 | ) 27 | 28 | def test_valid_dict_rule(self): 29 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', {'gt': 1.5, 'lt': 2.1}) 30 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 31 | 'validate_operators')) as m: 32 | vr.valid_dict_rule() 33 | args, kwargs = m.call_args 34 | self.assertTrue(m.called) 35 | self.assertEqual(args, ({'gt': 1.5, 'lt': 2.1}, float)) 36 | self.assertEqual(kwargs, {}) 37 | 38 | def test_is_valid(self): 39 | # Rule value must be a float 40 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', '') 41 | err = f'The value of the rule "ASPECT_RATIO", "", ' \ 42 | f'should be either a float or dict.' 43 | with self.assertRaisesMessage(InvalidValueError, err): 44 | vr.is_valid() 45 | 46 | # positive int (invalid) 47 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', 1) 48 | err = f'The value of the rule "ASPECT_RATIO", "1", ' \ 49 | f'should be either a float or dict.' 50 | with self.assertRaisesMessage(InvalidValueError, err): 51 | vr.is_valid() 52 | 53 | # negative float (invalid) 54 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', -1.5) 55 | err = f'The value of the rule "ASPECT_RATIO", "-1.5", ' \ 56 | f'should be a positive float.' 57 | with self.assertRaisesMessage(InvalidValueError, err): 58 | vr.is_valid() 59 | 60 | # invalid value (empty dict) 61 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', {}) 62 | err = f'The value of the rule "ASPECT_RATIO", "{{}}", ' \ 63 | f'should be a non-empty dict.' 64 | with self.assertRaisesMessage(InvalidValueError, err): 65 | vr.is_valid() 66 | 67 | # valid value (correct dict) 68 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', {'eq': 1.0}) 69 | m_path = dotted_path('validator_types', 'ValidationRuleAspectRatio', 70 | 'valid_dict_rule') 71 | with patch(m_path) as m: 72 | vr.is_valid() 73 | self.assertTrue(m.called) 74 | 75 | # valid values 76 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', 1.0) 77 | self.assertIsNone(vr.is_valid()) 78 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', 2.3) 79 | self.assertIsNone(vr.is_valid()) 80 | 81 | 82 | class ValidationRuleAspectRatioValidatorTestCase(ValidatorTestCase): 83 | def test_generator_is_function(self): 84 | vr = ValidationRuleAspectRatio('a', 1.0) 85 | validator = vr.generate_validator() 86 | self.assertIsInstance(validator, FunctionType) 87 | 88 | def test_generator_docstring(self): 89 | vr = ValidationRuleAspectRatio('a', 1.0) 90 | validator = vr.generate_validator() 91 | self.assertEqual(validator.__doc__, 'a: 1.0') 92 | 93 | def test_generate_validator__valid(self): 94 | valid_rules = [ 95 | ValidationRuleAspectRatio('ASPECT_RATIO', 1.0), 96 | ValidationRuleAspectRatio('ASPECT_RATIO', {'gte': 1.0}), 97 | ValidationRuleAspectRatio('ASPECT_RATIO', {'lt': 1.1}), 98 | ValidationRuleAspectRatio('ASPECT_RATIO', {'gt': 0.9, 'lt': 1.1}), 99 | ] 100 | for valid_rule in valid_rules: 101 | with self.subTest(valid_rule=valid_rule): 102 | validator = valid_rule.generate_validator() 103 | self.assertIsNone(validator(self.img)) 104 | 105 | def test_generate_validator__invalid(self): 106 | invalid_rules = [ 107 | ValidationRuleAspectRatio('ASPECT_RATIO', 2.0), 108 | ValidationRuleAspectRatio('ASPECT_RATIO', {'gt': 1.0}), 109 | ValidationRuleAspectRatio('ASPECT_RATIO', {'lt': 0.8}), 110 | ValidationRuleAspectRatio('ASPECT_RATIO', {'gt': 1.9, 'lt': 1.1}), 111 | ] 112 | for invalid_rule in invalid_rules: 113 | with self.subTest(invalid_rule=invalid_rule): 114 | with self.assertRaises(ValidationError): 115 | validator = invalid_rule.generate_validator() 116 | validator(self.img) 117 | 118 | def test_generate_validator_custom_error(self): 119 | vr = ValidationRuleAspectRatio('ASPECT_RATIO', { 120 | 'lt': 0.3, 121 | 'err': 'aspect ratio error here!', 122 | }) 123 | validator = vr.generate_validator() 124 | err = 'aspect ratio error here!' 125 | with self.assertRaisesMessage(ValidationError, err): 126 | validator(self.img) 127 | -------------------------------------------------------------------------------- /tests/test_suites/test_validation_rule_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import operator 3 | from unittest.mock import patch 4 | 5 | from django.test import TestCase 6 | from django.utils.safestring import SafeText 7 | 8 | from vimage.core import const 9 | from vimage.core import validator_types 10 | from vimage.core.exceptions import InvalidValueError 11 | from tests.apps.myapp.models import MyModel 12 | 13 | from .const import IMAGES_PATH, dotted_path, image 14 | 15 | 16 | class ValidationRuleBaseTestCase1(TestCase): 17 | def setUp(self): 18 | self.vr = validator_types.ValidationRuleBase('SIZE', 100) 19 | 20 | def test_init(self): 21 | self.assertEqual(self.vr.name, 'SIZE') 22 | self.assertEqual(self.vr.rule, 100) 23 | self.assertEqual(self.vr.equal, operator.eq) 24 | self.assertEqual(self.vr.trans_name, const.trans_type[self.vr.name]) 25 | 26 | def test_str(self): 27 | self.assertEqual(str(self.vr), 'SIZE: 100') 28 | 29 | def test_repr(self): 30 | self.assertEqual(repr(self.vr), 'ValidationRuleBase(\'SIZE\', 100)') 31 | vr = validator_types.ValidationRuleBase('SIZE', {'gt': 100}) 32 | self.assertEqual( 33 | repr(vr), 34 | 'ValidationRuleBase(\'SIZE\', {\'gt\': 100})' 35 | ) 36 | 37 | def test_positive_num(self): 38 | with self.assertRaises(TypeError): 39 | self.vr.positive_num('') 40 | self.assertTrue(self.vr.positive_num(1)) 41 | self.assertFalse(self.vr.positive_num(0)) 42 | self.assertFalse(self.vr.positive_num(-1)) 43 | 44 | def test_positive_elements(self): 45 | invalid_values = [ 46 | '', # not int 47 | ['a'], # not int 48 | [-1, -2], # not positive 49 | [0, 0], # not positive 50 | [0, 10], # not positive 51 | [10, 0], # not positive 52 | ] 53 | valid_values = [ 54 | [1], 55 | [1, 1], 56 | [10, 10, 10], 57 | (1, 2, 3), 58 | ] 59 | for value in invalid_values: 60 | with self.subTest(value=value): 61 | self.assertFalse(self.vr.positive_elements(value)) 62 | for value in valid_values: 63 | with self.subTest(value=value): 64 | self.assertTrue(self.vr.positive_elements(value)) 65 | 66 | def test_positive_two_len_tuple(self): 67 | invalid_values = [ 68 | [1], # not tuple 69 | (1,), # positive but not 2-len 70 | [10, 10], # positive, 2-len but not tuple 71 | (-1, -2), # not positive 72 | (1, -2), # not positive 73 | (-2, 2), # not positive 74 | [10, 0], # not tuple 75 | ] 76 | for value in invalid_values: 77 | with self.subTest(value=value): 78 | self.assertFalse(self.vr.positive_two_len_tuple(value)) 79 | 80 | self.assertTrue(self.vr.positive_two_len_tuple((1, 2))) 81 | 82 | def test_positive_list(self): 83 | invalid_values = [ 84 | '', # not a list 85 | [''], # a list but not a tuple inside 86 | [(-1, -2)], # negative tuple elements 87 | [(1, 2, 3)], # 3-len tuple instead of 2 88 | [(1, 0)], # 2-len tuple but not positive 89 | ] 90 | for value in invalid_values: 91 | with self.subTest(value=value): 92 | self.assertFalse(self.vr.positive_list(value)) 93 | 94 | self.assertTrue(self.vr.positive_list( 95 | [(1, 2), (3, 4)] 96 | )) 97 | 98 | def test_only_err_key(self): 99 | vr = validator_types.ValidationRuleBase('SIZE', {'err': 'aa'}) 100 | err = f'The value of the rule "SIZE", "{{\'err\': \'aa\'}}", ' \ 101 | f'should consist of at least one operator key.' 102 | with self.assertRaisesMessage(InvalidValueError, err): 103 | vr.only_err_key(vr.rule.keys()) 104 | 105 | vr = validator_types.ValidationRuleBase('SIZE', {'gt': 1, 'err': 'a'}) 106 | self.assertIsNone(vr.only_err_key(vr.rule.keys())) 107 | 108 | def test_nonsense_operators(self): 109 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': 1, 'eq': 2}) 110 | with self.assertRaises(InvalidValueError): 111 | vr.validate_operators(vr.rule, int) 112 | 113 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': 1, 'lte': 2}) 114 | self.assertIsNone(vr.validate_operators(vr.rule, int)) 115 | 116 | def test_valid_dict_rule_str(self): 117 | # too many keys (invalid) 118 | vr = validator_types.ValidationRuleBase('FORMAT', { 119 | 'eq': 'jpeg', 120 | 'ne': 'png', 121 | 'other': 'other', 122 | }) 123 | with self.assertRaises(InvalidValueError): 124 | vr.valid_dict_rule_str() 125 | 126 | # test that "only_err_key()" is called 127 | vr = validator_types.ValidationRuleBase('FORMAT', {'err': 'aa'}) 128 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 129 | 'only_err_key')) as m: 130 | vr.valid_dict_rule_str() 131 | self.assertTrue(m.called) 132 | 133 | # test that "nonsense_operators()" is called 134 | vr = validator_types.ValidationRuleBase('FORMAT', { 135 | 'ne': 'jpeg', 136 | 'eq': 'png', 137 | }) 138 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 139 | 'nonsense_operators')) as m: 140 | vr.valid_dict_rule_str() 141 | self.assertTrue(m.called) 142 | 143 | # invalid key (gt) for str comparisons (invalid) 144 | vr = validator_types.ValidationRuleBase('FORMAT', { 145 | 'ne': 'jpeg', 146 | 'gt': 'png', 147 | }) 148 | err = f'The value of the rule "FORMAT", ' \ 149 | f'"{{\'ne\': \'jpeg\', \'gt\': \'png\'}}", has encountered ' \ 150 | f'an invalid key "gt". Valid keys: eq, ne, err.' 151 | with self.assertRaisesMessage(InvalidValueError, err): 152 | vr.valid_dict_rule_str() 153 | 154 | # valid keys 155 | valid_keys = [ 156 | validator_types.ValidationRuleBase( 157 | 'SIZE', {'ne': 'a', 'err': 'a'} 158 | ), 159 | validator_types.ValidationRuleBase('SIZE', {'ne': 'jpeg'}) 160 | ] 161 | for valid_key in valid_keys: 162 | with self.subTest(valid_key=valid_key): 163 | self.assertIsNone(valid_key.valid_dict_rule_str()) 164 | 165 | 166 | class ValidationRuleBaseTestCase2(TestCase): 167 | def test_validate_operators_keys(self): 168 | # test that "only_err_key()" is called 169 | vr = validator_types.ValidationRuleBase('FORMAT', {'err': 'aa'}) 170 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 171 | 'only_err_key')) as m: 172 | vr.validate_operators(vr.rule, int) 173 | self.assertTrue(m.called) 174 | 175 | # test that "nonsense_operators()" is called 176 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': 1, 'eq': 2}) 177 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 178 | 'nonsense_operators')) as m: 179 | vr.validate_operators(vr.rule, int) 180 | self.assertTrue(m.called) 181 | 182 | # invalid rule keys (unknown) 183 | vr = validator_types.ValidationRuleBase('SIZE', {'a': 1}) 184 | err = f'Encountered invalid key, "a" inside "{{\'a\': 1}}"!' 185 | with self.assertRaisesMessage(InvalidValueError, err): 186 | vr.validate_operators(vr.rule, int) 187 | 188 | valid_operators_int = [ 189 | {'gt': 100, 'lte': 100}, 190 | {'gt': 1, 'lt': 2}, 191 | {'ne': 900}, 192 | ] 193 | for value in valid_operators_int: 194 | with self.subTest(value=value): 195 | vr.rule = value 196 | self.assertIsNone(vr.validate_operators(value, int)) 197 | 198 | valid_operators_tuple = [ 199 | {'gt': (100, 100), 'lte': (500, 500)}, 200 | {'gt': (100, 100), 'lt': (200, 200)}, 201 | {'ne': (500, 500)}, 202 | ] 203 | for value in valid_operators_tuple: 204 | with self.subTest(value=value): 205 | vr.rule = value 206 | self.assertIsNone(vr.validate_operators(value, tuple)) 207 | 208 | def test_validate_operators_dict_values(self): 209 | # rule value is a str, it should be an int 210 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': ''}) 211 | err = f'The value of the key "gte", inside "{{\'gte\': \'\'}}", ' \ 212 | f'should be "int". Now it\'s type is "str".' 213 | with self.assertRaisesMessage(InvalidValueError, err): 214 | vr.validate_operators(vr.rule, int) 215 | 216 | # rule value is a negative int, it should be positive 217 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': -1}) 218 | err = f'The value of the key "gte", inside "{{\'gte\': -1}}", ' \ 219 | f'should be a positive integer. Now it\'s value is "-1".' 220 | with self.assertRaisesMessage(InvalidValueError, err): 221 | vr.validate_operators(vr.rule, int) 222 | 223 | # rule value's tuple contains negative element, must be all positive 224 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': (1, -1)}) 225 | err = f'The value of the key "gte", inside "{{\'gte\': (1, -1)}}", ' \ 226 | f'should consist of tuples with two positive ' \ 227 | f'integers, each. Now it\'s value is "(1, -1)".' 228 | with self.assertRaisesMessage(InvalidValueError, err): 229 | vr.validate_operators(vr.rule, tuple) 230 | 231 | # valid rule values 232 | vr = validator_types.ValidationRuleBase('SIZE', {'gte': 1}) 233 | self.assertIsNone(vr.validate_operators(vr.rule, int)) 234 | vr = validator_types.ValidationRuleBase('DIMENSIONS', { 235 | 'gt': (100, 100) 236 | }) 237 | self.assertIsNone(vr.validate_operators(vr.rule, tuple)) 238 | vr = validator_types.ValidationRuleBase('DIMENSIONS', { 239 | 'gt': (100, 100), 240 | 'err': 'custom error here', 241 | }) 242 | self.assertIsNone(vr.validate_operators(vr.rule, tuple)) 243 | 244 | 245 | class ValidationRuleBaseTestCase3(ValidationRuleBaseTestCase1): 246 | def test_humanize_dict_rules(self): 247 | rule = {'gt': 10, 'lt': 20} 248 | unit = 'KB' 249 | human_dict_rules = self.vr.humanize_dict_rules(rule=rule, unit=unit) 250 | self.assertIsInstance(human_dict_rules, list) 251 | 252 | # No human_func provided. Values (10, 20) displayed as is. 253 | self.assertIn('greater than 10KB', human_dict_rules) 254 | self.assertIn('less than 20KB', human_dict_rules) 255 | 256 | rule = {'ne': 'jpeg'} 257 | # human_func is str.upper. Each value should be uppercase. 258 | human_dict_rules = self.vr.humanize_dict_rules(rule, str.upper) 259 | self.assertIn('not equal to JPEG', human_dict_rules) 260 | 261 | def test_format_humanized_rules_default_kwargs(self): 262 | # single rule - keep defaults 263 | rules = ['greater than 10KB'] 264 | human_rules = self.vr.format_humanized_rules(rules=rules) 265 | self.assertIsInstance(human_rules, str) 266 | self.assertEqual(human_rules, 'greater than 10KB') 267 | 268 | # two rules - keep defaults 269 | rules = ['greater than 10KB', 'less than 20KB'] 270 | human_rules = self.vr.format_humanized_rules(rules=rules) 271 | self.assertEqual(human_rules, 'greater than 10KB and less than 20KB') 272 | 273 | # three rules - keep defaults 274 | rules = ['greater than 10KB', 'less than 20KB', 'not equal to 15KB'] 275 | human_rules = self.vr.format_humanized_rules(rules=rules) 276 | self.assertEqual( 277 | human_rules, 278 | 'greater than 10KB, less than 20KB and not equal to 15KB' 279 | ) 280 | 281 | def test_format_humanized_rules_custom_separator(self): 282 | sep = 'or' 283 | 284 | # single rule - "or" separator 285 | rules = ['greater than 10KB'] 286 | human_rules = self.vr.format_humanized_rules(rules=rules, sep=sep) 287 | self.assertEqual(human_rules, 'greater than 10KB') 288 | 289 | # two rules - "or" separator 290 | rules = ['greater than 10KB', 'less than 20KB'] 291 | human_rules = self.vr.format_humanized_rules(rules=rules, sep=sep) 292 | self.assertEqual(human_rules, 'greater than 10KB or less than 20KB') 293 | 294 | # three rules - "or" separator 295 | rules = ['greater than 10KB', 'less than 20KB', 'not equal to 15KB'] 296 | human_rules = self.vr.format_humanized_rules(rules=rules, sep=sep) 297 | self.assertEqual( 298 | human_rules, 299 | 'greater than 10KB, less than 20KB or not equal to 15KB' 300 | ) 301 | 302 | def test_format_humanized_rules_custom_prefix(self): 303 | pref = 'AA' 304 | 305 | # single rule - custom prefix 306 | rules = ['greater than 10KB'] 307 | human_rules = self.vr.format_humanized_rules(rules=rules, prefix=pref) 308 | self.assertEqual(human_rules, 'greater than 10KB') 309 | 310 | # two rules - custom prefix 311 | rules = ['greater than 10KB', 'less than 20KB'] 312 | human_rules = self.vr.format_humanized_rules(rules=rules, prefix=pref) 313 | self.assertEqual( 314 | human_rules, 315 | 'AA greater than 10KB and less than 20KB' 316 | ) 317 | 318 | # three rules - custom prefix 319 | rules = ['greater than 10KB', 'less than 20KB', 'not equal to 15KB'] 320 | human_rules = self.vr.format_humanized_rules(rules=rules, prefix=pref) 321 | self.assertEqual( 322 | human_rules, 323 | 'AA greater than 10KB, less than 20KB and not equal to 15KB' 324 | ) 325 | 326 | def test_render_human_rule(self): 327 | rule = 'greater than 10KB' 328 | 329 | safe_rule = self.vr.render_human_rule(rule) 330 | self.assertIsInstance(safe_rule, SafeText) 331 | 332 | safe_rule = self.vr.render_human_rule(rule, safe=False) 333 | self.assertIsInstance(safe_rule, str) 334 | 335 | def test_error_message_template(self): 336 | template = self.vr.error_message_template('SIZE', '100KB', 'rule here') 337 | self.assertEqual( 338 | '[IMAGE SIZE] Validation error: ' 339 | '100KB does not meet validation rule: ' 340 | 'rule here.', 341 | template 342 | ) 343 | 344 | def test_build_tests(self): 345 | vr = validator_types.ValidationRuleBase('', {'gte': 100, 'lte': 500}) 346 | self.assertEqual( 347 | vr.build_tests(vr.rule, 400), 348 | ([operator.ge(400, 100), operator.le(400, 500)], '') 349 | ) 350 | 351 | vr = validator_types.ValidationRuleBase('', { 352 | 'gte': 100, 353 | 'lte': 500, 354 | 'err': 'hello', 355 | }) 356 | self.assertEqual( 357 | vr.build_tests(vr.rule, 400), 358 | ([operator.ge(400, 100), operator.le(400, 500)], 'hello') 359 | ) 360 | 361 | vr = validator_types.ValidationRuleBase('', { 362 | 'eq': 'jpeg', 363 | 'err': 'hello', 364 | }) 365 | self.assertEqual( 366 | vr.build_tests(vr.rule, 'jpeg'), 367 | ([operator.eq('jpeg', 'jpeg')], 'hello') 368 | ) 369 | 370 | 371 | class ValidationRuleFactoryTestCase(TestCase): 372 | def test_validation_rule_factory_valid(self): 373 | self.assertIsInstance( 374 | validator_types.validation_rule_factory('SIZE', ''), 375 | validator_types.ValidationRuleSize 376 | ) 377 | self.assertIsInstance( 378 | validator_types.validation_rule_factory('DIMENSIONS', ''), 379 | validator_types.ValidationRuleDimensions 380 | ) 381 | self.assertIsInstance( 382 | validator_types.validation_rule_factory('FORMAT', ''), 383 | validator_types.ValidationRuleFormat 384 | ) 385 | self.assertIsInstance( 386 | validator_types.validation_rule_factory('ASPECT_RATIO', ''), 387 | validator_types.ValidationRuleAspectRatio 388 | ) 389 | 390 | def test_validation_rule_factory_invalid(self): 391 | inv = 'INVALID_TYPE' 392 | err = f'[{inv}]: This is not a valid key for a value. ' \ 393 | f'Valid values are "SIZE, DIMENSIONS, FORMAT, ASPECT_RATIO"' 394 | with self.assertRaisesMessage(InvalidValueError, err): 395 | validator_types.validation_rule_factory(inv, '') 396 | 397 | 398 | class ValidatorTestCase(TestCase): 399 | def setUp(self): 400 | path_to_image = os.path.join(IMAGES_PATH, '500x498-100KB.jpeg') 401 | img = image(name='test_image_size', path=path_to_image) 402 | my_model = MyModel(heading='heading', img=img, number=1) 403 | self.img = my_model.img 404 | -------------------------------------------------------------------------------- /tests/test_suites/test_validation_rule_dimensions.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from django.core.exceptions import ValidationError 6 | 7 | from vimage.core.validator_types import ValidationRuleDimensions 8 | from vimage.core.exceptions import InvalidValueError 9 | 10 | from .test_validation_rule_base import ValidatorTestCase 11 | from .const import dotted_path 12 | 13 | 14 | class ValidationRuleDimensionsTestCase(TestCase): 15 | def test_init(self): 16 | vr = ValidationRuleDimensions('DIMENSIONS', 100) 17 | self.assertEqual(vr.unit, 'px') 18 | 19 | def test_humanize_rule(self): 20 | vr = ValidationRuleDimensions('DIMENSIONS', (400, 400)) 21 | self.assertEqual(vr.humanize_rule(), 'equal to 400 x 400px') 22 | 23 | vr = ValidationRuleDimensions('DIMENSIONS', [(40, 40), (50, 50)]) 24 | self.assertEqual( 25 | vr.humanize_rule(), 26 | 'equal to one of the following dimensions 40 x 40px or 50 x 50px' 27 | ) 28 | 29 | vr = ValidationRuleDimensions('DIMENSIONS', [(4, 4), (5, 5), (6, 6)]) 30 | self.assertEqual( 31 | vr.humanize_rule(), 32 | 'equal to one of the following dimensions 4 x 4px, 5 x 5px ' 33 | 'or 6 x 6px' 34 | ) 35 | 36 | def test_humanize_rule_dict(self): 37 | vr = ValidationRuleDimensions('DIMENSIONS', { 38 | 'w': { 39 | 'gte': 1000, 40 | 'lte': 1500, 41 | }, 42 | 'h': { 43 | 'gt': 500, 44 | 'lt': 600, 45 | } 46 | }) 47 | self.assertEqual( 48 | vr.humanize_rule(), 49 | 'Width greater than or equal to 1000px and less than or equal to ' 50 | '1500px. Height greater than 500px and less than 600px' 51 | ) 52 | 53 | vr = ValidationRuleDimensions('DIMENSIONS', { 54 | 'w': { 55 | 'gte': 1000, 56 | 'lte': 1500, 57 | }, 58 | }) 59 | self.assertEqual( 60 | vr.humanize_rule(), 61 | 'Width greater than or equal to 1000px and less than or equal to ' 62 | '1500px' 63 | ) 64 | 65 | vr = ValidationRuleDimensions('DIMENSIONS', { 66 | 'h': { 67 | 'gt': 500, 68 | 'lt': 600, 69 | }, 70 | }) 71 | self.assertEqual( 72 | vr.humanize_rule(), 73 | 'Height greater than 500px and less than 600px' 74 | ) 75 | 76 | vr = ValidationRuleDimensions('DIMENSIONS', { 77 | 'gt': (500, 500), 78 | 'lt': (600, 600), 79 | }) 80 | self.assertEqual( 81 | vr.humanize_rule(), 82 | 'greater than 500 x 500px and less than 600 x 600px' 83 | ) 84 | 85 | def test_prettify_list(self): 86 | vr = ValidationRuleDimensions('DIMENSIONS', [(3, 3), (4, 4)]) 87 | self.assertListEqual( 88 | vr.prettify_list([(3, 3), (4, 4)]), 89 | ['3 x 3px', '4 x 4px'] 90 | ) 91 | 92 | def test_prettify_value(self): 93 | vr = ValidationRuleDimensions('DIMENSIONS', (3, 3)) 94 | self.assertEqual(vr.prettify_value((3, 3)), '3 x 3px') 95 | self.assertEqual(vr.prettify_value((3, 3), 'Χ'), '3 Χ 3px') 96 | 97 | def test_has_width_height_keys(self): 98 | # Rule value must be a dict 99 | vr = ValidationRuleDimensions('DIMENSIONS', 100) 100 | with self.assertRaises(AttributeError): 101 | vr.has_width_height_keys() 102 | 103 | width_height_rules = [ 104 | ValidationRuleDimensions('DIMENSIONS', {'w': 1}), 105 | ValidationRuleDimensions('DIMENSIONS', {'h': 1}), 106 | ValidationRuleDimensions('DIMENSIONS', {'w': 1, 'h': 1}), 107 | ] 108 | for rule in width_height_rules: 109 | with self.subTest(rule=rule): 110 | self.assertTrue(rule.has_width_height_keys()) 111 | 112 | no_width_height_rules = [ 113 | ValidationRuleDimensions('DIMENSIONS', {}), 114 | ValidationRuleDimensions('DIMENSIONS', {'gte': 100}), 115 | ] 116 | for rule in no_width_height_rules: 117 | with self.subTest(rule=rule): 118 | self.assertFalse(rule.has_width_height_keys()) 119 | 120 | @patch(dotted_path('validator_types', 'ValidationRuleDimensions', 121 | 'validate_operators')) 122 | def test_valid_dict_rule_width_height(self, patch_method): 123 | vr = ValidationRuleDimensions('DIMENSIONS', {'w': {}}) 124 | vr.valid_dict_rule() 125 | args, kwargs = patch_method.call_args 126 | self.assertTrue(patch_method.called) 127 | self.assertEqual(args, ({}, int)) 128 | self.assertEqual(kwargs, {}) 129 | 130 | @patch(dotted_path('validator_types', 'ValidationRuleDimensions', 131 | 'validate_operators')) 132 | def test_valid_dict_rule_wo_width_height(self, patch_method): 133 | vr = ValidationRuleDimensions('DIMENSIONS', {}) 134 | vr.valid_dict_rule() 135 | args, kwargs = patch_method.call_args 136 | self.assertTrue(patch_method.called) 137 | self.assertEqual(args, ({}, tuple)) 138 | self.assertEqual(kwargs, {}) 139 | 140 | def test_is_valid__dimensions_rule_type(self): 141 | """ 142 | "rule" should be either a tuple, a list or a dict filled with proper 143 | key-value validation rules. 144 | """ 145 | vr = ValidationRuleDimensions('DIMENSIONS', '') 146 | err = f'The value of the rule "DIMENSIONS", "", ' \ 147 | f'should be either a tuple, a list or a dict.' 148 | with self.assertRaisesMessage(InvalidValueError, err): 149 | vr.is_valid() 150 | 151 | vr = ValidationRuleDimensions('DIMENSIONS', 12) 152 | err = f'The value of the rule "DIMENSIONS", "12", ' \ 153 | f'should be either a tuple, a list or a dict.' 154 | with self.assertRaisesMessage(InvalidValueError, err): 155 | vr.is_valid() 156 | 157 | def test_is_valid__dimensions_rule_tuple(self): 158 | invalid_vrs = [ 159 | ValidationRuleDimensions('DIMENSIONS', ()), 160 | ValidationRuleDimensions('DIMENSIONS', (10,)), 161 | ValidationRuleDimensions('DIMENSIONS', (-10, 10)), 162 | ValidationRuleDimensions('DIMENSIONS', (-10, -10)), 163 | ValidationRuleDimensions('DIMENSIONS', (10, 10, 10)), 164 | ] 165 | for vr in invalid_vrs: 166 | with self.subTest(vr=vr): 167 | err = f'The value of the rule "DIMENSIONS", "{vr.rule}", ' \ 168 | f'should consist of two positive integers.' 169 | with self.assertRaisesMessage(InvalidValueError, err): 170 | vr.is_valid() 171 | 172 | vr = ValidationRuleDimensions('DIMENSIONS', (10, 10)) 173 | self.assertIsNone(vr.is_valid()) 174 | 175 | def test_is_valid__dimensions_rule_list(self): 176 | invalid_vrs = [ 177 | ValidationRuleDimensions('DIMENSIONS', []), 178 | ValidationRuleDimensions('DIMENSIONS', [(10,)]), 179 | ValidationRuleDimensions('DIMENSIONS', [(10, -10)]), 180 | ValidationRuleDimensions('DIMENSIONS', [(10, 10), (-10, 10)]), 181 | ] 182 | for vr in invalid_vrs: 183 | with self.subTest(vr=vr): 184 | err = f'The value of the rule "DIMENSIONS", "{vr.rule}", ' \ 185 | f'should consist of tuples with two positive ' \ 186 | f'integers, each.' 187 | with self.assertRaisesMessage(InvalidValueError, err): 188 | vr.is_valid() 189 | 190 | vr = ValidationRuleDimensions('DIMENSIONS', [(10, 10), (5, 5)]) 191 | self.assertIsNone(vr.is_valid()) 192 | 193 | def test_is_valid__dimensions_rule_dict(self): 194 | vr = ValidationRuleDimensions('DIMENSIONS', {}) 195 | err = f'The value of the rule "DIMENSIONS", "{{}}", ' \ 196 | f'should be a non-empty dict.' 197 | with self.assertRaisesMessage(InvalidValueError, err): 198 | vr.is_valid() 199 | 200 | with patch(dotted_path('validator_types', 'ValidationRuleDimensions', 201 | 'valid_dict_rule')) as m: 202 | vr = ValidationRuleDimensions('DIMENSIONS', {'gte': 100}) 203 | vr.is_valid() 204 | # if dict is non-empty check that "valid_dict_rule" is called. 205 | self.assertTrue(m.called) 206 | 207 | 208 | class ValidationRuleDimensionsValidatorTestCase(ValidatorTestCase): 209 | def test_generator_is_function(self): 210 | vr = ValidationRuleDimensions('a', 1) 211 | validator = vr.generate_validator() 212 | self.assertIsInstance(validator, FunctionType) 213 | 214 | def test_generator_docstring(self): 215 | vr = ValidationRuleDimensions('a', 1) 216 | validator = vr.generate_validator() 217 | self.assertEqual(validator.__doc__, 'a: 1') 218 | 219 | def test_generate_validator_tuple_valid(self): 220 | vr = ValidationRuleDimensions('DIMENSIONS', (500, 498)) 221 | validator = vr.generate_validator() 222 | self.assertIsNone(validator(self.img)) 223 | 224 | def test_generate_validator_tuple_invalid(self): 225 | vr = ValidationRuleDimensions('DIMENSIONS', (500, 500)) 226 | validator = vr.generate_validator() 227 | with self.assertRaises(ValidationError): 228 | validator(self.img) 229 | 230 | def test_generate_validator_list_valid(self): 231 | vr = ValidationRuleDimensions('DIMENSIONS', [(500, 500), (500, 498)]) 232 | validator = vr.generate_validator() 233 | self.assertIsNone(validator(self.img)) 234 | 235 | def test_generate_validator_list_invalid(self): 236 | vr = ValidationRuleDimensions('DIMENSIONS', [(500, 500), (100, 100)]) 237 | validator = vr.generate_validator() 238 | with self.assertRaises(ValidationError): 239 | validator(self.img) 240 | 241 | def test_generate_validator_dict_valid(self): 242 | valid_rules = [ 243 | ValidationRuleDimensions('DIMENSIONS', { 244 | 'gte': (500, 498), 245 | 'lte': (500, 498), 246 | }), 247 | ValidationRuleDimensions('DIMENSIONS', {'lt': (600, 600)}), 248 | ValidationRuleDimensions('DIMENSIONS', {'lte': (500, 498)}), 249 | ValidationRuleDimensions('DIMENSIONS', {'gte': (500, 498)}), 250 | ValidationRuleDimensions('DIMENSIONS', { 251 | 'gte': (500, 498), 252 | 'lte': (500, 498), 253 | 'ne': (100, 100), 254 | }), 255 | ValidationRuleDimensions('DIMENSIONS', { 256 | 'gte': (500, 498), 257 | 'lte': (500, 498), 258 | 'ne': (100, 100), 259 | 'err': 'dimensions error message', 260 | }), 261 | ValidationRuleDimensions('DIMENSIONS', {'ne': (100, 100)}), 262 | ValidationRuleDimensions('DIMENSIONS', {'eq': (500, 498)}), 263 | ValidationRuleDimensions('DIMENSIONS', { 264 | 'w': { 265 | 'gt': 100, 266 | }, 267 | 'h': { 268 | 'eq': 498, 269 | } 270 | }), 271 | ValidationRuleDimensions('DIMENSIONS', { 272 | 'w': { 273 | 'eq': 500, 274 | }, 275 | }), 276 | ValidationRuleDimensions('DIMENSIONS', { 277 | 'h': { 278 | 'eq': 498, 279 | 'err': 'width error message', 280 | } 281 | }), 282 | ValidationRuleDimensions('DIMENSIONS', { 283 | 'w': { 284 | 'gt': 100, 285 | 'err': 'width error message', 286 | }, 287 | 'h': { 288 | 'eq': 498, 289 | 'err': 'height error message', 290 | } 291 | }), 292 | ] 293 | for vr in valid_rules: 294 | with self.subTest(vr=vr): 295 | validator = vr.generate_validator() 296 | self.assertIsNone(validator(self.img)) 297 | 298 | def test_generate_validator_dict_invalid(self): 299 | invalid_rules = [ 300 | ValidationRuleDimensions('DIMENSIONS', {'gte': (150, 1000)}), 301 | ValidationRuleDimensions('DIMENSIONS', {'lt': (500, 498)}), 302 | ValidationRuleDimensions('DIMENSIONS', {'lte': (300, 400)}), 303 | ValidationRuleDimensions('DIMENSIONS', { 304 | 'gte': (150, 700), 305 | 'lte': (100, 100), 306 | }), 307 | ValidationRuleDimensions('DIMENSIONS', { 308 | 'gte': (600, 100), 309 | 'lt': (1000, 1000), 310 | 'ne': (450, 450), 311 | }), 312 | ValidationRuleDimensions('DIMENSIONS', {'ne': (500, 498)}), 313 | ValidationRuleDimensions('DIMENSIONS', {'eq': (50, 498)}), 314 | ValidationRuleDimensions('DIMENSIONS', { 315 | 'w': { 316 | 'lt': 100, 317 | }, 318 | 'h': { 319 | 'eq': 498, 320 | } 321 | }), 322 | ValidationRuleDimensions('DIMENSIONS', { 323 | 'h': { 324 | 'ne': 498, 325 | } 326 | }), 327 | ValidationRuleDimensions('DIMENSIONS', { 328 | 'w': { 329 | 'lte': 499, 330 | }, 331 | }), 332 | ] 333 | for vr in invalid_rules: 334 | with self.subTest(vr=vr): 335 | validator = vr.generate_validator() 336 | with self.assertRaises(ValidationError): 337 | validator(self.img) 338 | 339 | def test_generate_validator_custom_error(self): 340 | # testing custom width/height error messages 341 | vr = ValidationRuleDimensions('DIMENSIONS', { 342 | 'w': { 343 | 'gt': 500, 344 | 'err': 'width error message.', 345 | }, 346 | 'h': { 347 | 'eq': 100, 348 | 'err': 'height error message.', 349 | } 350 | }) 351 | validator = vr.generate_validator() 352 | err = 'width error message. height error message' 353 | with self.assertRaisesMessage(ValidationError, err): 354 | validator(self.img) 355 | 356 | vr = ValidationRuleDimensions('DIMENSIONS', { 357 | 'w': { 358 | 'gt': 500, 359 | 'err': 'width error message.', 360 | }, 361 | 'h': { 362 | 'eq': 100, 363 | } 364 | }) 365 | validator = vr.generate_validator() 366 | err = 'width error message.' 367 | with self.assertRaisesMessage(ValidationError, err): 368 | validator(self.img) 369 | 370 | vr = ValidationRuleDimensions('DIMENSIONS', { 371 | 'w': { 372 | 'gt': 500, 373 | 'err': 'width error message.', 374 | } 375 | }) 376 | validator = vr.generate_validator() 377 | err = 'width error message.' 378 | with self.assertRaisesMessage(ValidationError, err): 379 | validator(self.img) 380 | 381 | vr = ValidationRuleDimensions('DIMENSIONS', { 382 | 'lt': (50, 50), 383 | 'err': 'error here!', 384 | }) 385 | validator = vr.generate_validator() 386 | err = 'error here!' 387 | with self.assertRaisesMessage(ValidationError, err): 388 | validator(self.img) 389 | -------------------------------------------------------------------------------- /tests/test_suites/test_validation_rule_format.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from django.core.exceptions import ValidationError 6 | 7 | from vimage.core.validator_types import ValidationRuleFormat 8 | from vimage.core.exceptions import InvalidValueError 9 | 10 | from .test_validation_rule_base import ValidatorTestCase 11 | from .const import dotted_path 12 | 13 | 14 | class ValidationRuleFormatTestCase(TestCase): 15 | def test_humanize_rule(self): 16 | vr = ValidationRuleFormat('FORMAT', 'jpeg') 17 | self.assertEqual(vr.humanize_rule(), 'equal to JPEG') 18 | 19 | vr = ValidationRuleFormat('FORMAT', ['jpeg']) 20 | self.assertEqual(vr.humanize_rule(), 'equal to JPEG') 21 | 22 | vr = ValidationRuleFormat('FORMAT', ['jpeg', 'webp']) 23 | self.assertEqual( 24 | vr.humanize_rule(), 25 | 'equal to one of the following formats JPEG or WEBP' 26 | ) 27 | 28 | vr = ValidationRuleFormat('FORMAT', {'ne': 'gif'}) 29 | self.assertEqual(vr.humanize_rule(), 'not equal to GIF') 30 | 31 | vr = ValidationRuleFormat('FORMAT', {'eq': 'gif'}) 32 | self.assertEqual(vr.humanize_rule(), 'equal to GIF') 33 | 34 | vr = ValidationRuleFormat('FORMAT', {'ne': ['gif', 'png']}) 35 | self.assertEqual( 36 | vr.humanize_rule(), 37 | 'not equal to the following formats GIF and PNG' 38 | ) 39 | 40 | def test_prettify_list(self): 41 | vr = ValidationRuleFormat('FORMAT', ['jpeg', 'png']) 42 | self.assertListEqual( 43 | vr.prettify_list(['jpeg', 'png']), 44 | ['JPEG', 'PNG'] 45 | ) 46 | 47 | def test_prettify_value(self): 48 | vr = ValidationRuleFormat('FORMAT', 'aaa') 49 | self.assertEqual(vr.prettify_value('aaa'), 'AAA') 50 | 51 | def test_valid_format(self): 52 | # rule value must be a str 53 | vr = ValidationRuleFormat('FORMAT', 100) 54 | self.assertFalse(vr.valid_format(vr.rule)) 55 | 56 | # str but not an allowable one 57 | vr = ValidationRuleFormat('FORMAT', 'hello') 58 | self.assertFalse(vr.valid_format(vr.rule)) 59 | 60 | # an allowable str 61 | vr = ValidationRuleFormat('FORMAT', 'png') 62 | self.assertTrue(vr.valid_format(vr.rule)) 63 | 64 | def test_valid_format_list(self): 65 | # Must be a non-empty list of allowable strings (image extensions) 66 | vr = ValidationRuleFormat('FORMAT', []) 67 | self.assertFalse(vr.valid_format_list(vr.rule)) 68 | 69 | vr = ValidationRuleFormat('FORMAT', ['a', 'b']) 70 | self.assertFalse(vr.valid_format_list(vr.rule)) 71 | 72 | # Valid 73 | vr = ValidationRuleFormat('FORMAT', ['jpeg', 'png', 'gif']) 74 | self.assertTrue(vr.valid_format_list(vr.rule)) 75 | 76 | def test_valid_dict_rule(self): 77 | """ Assumes that "self.rule" is a dict """ 78 | 79 | # dict with invalid key. Invalid. 80 | vr = ValidationRuleFormat('FORMAT', {'eq': 'jpeg'}) 81 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 82 | 'valid_dict_rule_str')) as m: 83 | vr.valid_dict_rule() 84 | self.assertTrue(m.called) 85 | 86 | # Dict with valid key but wrong value type 87 | vr = ValidationRuleFormat('FORMAT', {'ne': 1}) 88 | err = f'The value of the key "ne", ' \ 89 | f'inside "FORMAT: {{\'ne\': 1}}", ' \ 90 | f'should be either a str or a list. ' \ 91 | f'Now it\'s type is "int".' 92 | with self.assertRaisesMessage(InvalidValueError, err): 93 | vr.valid_dict_rule() 94 | 95 | # Dict with valid key but not an allowable str value 96 | vr = ValidationRuleFormat('FORMAT', {'ne': 'hello'}) 97 | err = f'The value of the key "ne", inside "FORMAT: ' \ 98 | f'{{\'ne\': \'hello\'}}", should be one of: ' \ 99 | f'"jpeg, png, gif, bmp, webp".' 100 | with self.assertRaisesMessage(InvalidValueError, err): 101 | vr.valid_dict_rule() 102 | 103 | # Dict with valid key, list value but not an allowable one 104 | vr = ValidationRuleFormat('FORMAT', {'ne': ['hello', 123]}) 105 | err = f'The value of the key "ne", inside "FORMAT: ' \ 106 | f'{{\'ne\': [\'hello\', 123]}}", should be one or more of: ' \ 107 | f'"jpeg, png, gif, bmp, webp".' 108 | with self.assertRaisesMessage(InvalidValueError, err): 109 | vr.valid_dict_rule() 110 | 111 | # Dict with valid key-value [str] 112 | vr = ValidationRuleFormat('FORMAT', {'ne': 'bmp'}) 113 | self.assertIsNone(vr.valid_dict_rule()) 114 | 115 | # Dict with valid key-value and custom error [str] 116 | vr = ValidationRuleFormat('FORMAT', {'ne': 'bmp', 'err': 'error here'}) 117 | self.assertIsNone(vr.valid_dict_rule()) 118 | 119 | # Dict with valid key-value [list] 120 | vr = ValidationRuleFormat('FORMAT', {'ne': ['bmp', 'gif']}) 121 | self.assertIsNone(vr.valid_dict_rule()) 122 | 123 | def test_is_valid__false(self): 124 | # Rule not one of [str, list, dict] 125 | vr = ValidationRuleFormat('FORMAT', 123) 126 | err = f'The value of the rule "FORMAT", "123", ' \ 127 | f'should be either a str, list or dict.' 128 | with self.assertRaisesMessage(InvalidValueError, err): 129 | vr.is_valid() 130 | 131 | # Rule is not an allowable string 132 | vr = ValidationRuleFormat('FORMAT', 'hello') 133 | err = f'The value of the rule "FORMAT", "hello", ' \ 134 | f'should be one of the valid formats: ' \ 135 | f'"jpeg, png, gif, bmp, webp".' 136 | with self.assertRaisesMessage(InvalidValueError, err): 137 | vr.is_valid() 138 | 139 | # Rule is not an allowable list 140 | vr = ValidationRuleFormat('FORMAT', ['hello']) 141 | err = f'The value of the rule "FORMAT", "[\'hello\']", ' \ 142 | f'should be one or more of the valid image formats: ' \ 143 | f'"jpeg, png, gif, bmp, webp".' 144 | with self.assertRaisesMessage(InvalidValueError, err): 145 | vr.is_valid() 146 | 147 | # Rule is an empty dict. Invalid 148 | vr = ValidationRuleFormat('FORMAT', {}) 149 | err = f'The value of the rule "FORMAT", "{{}}", ' \ 150 | f'should be a non-empty dict.' 151 | with self.assertRaisesMessage(InvalidValueError, err): 152 | vr.is_valid() 153 | 154 | def test_is_valid__true(self): 155 | vr = ValidationRuleFormat('FORMAT', 'jpeg') 156 | self.assertIsNone(vr.is_valid()) 157 | 158 | vr = ValidationRuleFormat('FORMAT', ['jpeg', 'bmp']) 159 | self.assertIsNone(vr.is_valid()) 160 | 161 | vr = ValidationRuleFormat('FORMAT', {'ne': 'bmp'}) 162 | with patch(dotted_path('validator_types', 'ValidationRuleFormat', 163 | 'valid_dict_rule')) as m: 164 | self.assertIsNone(vr.is_valid()) 165 | self.assertTrue(m.called) 166 | 167 | 168 | class ValidationRuleFormatValidatorTestCase(ValidatorTestCase): 169 | def test_generator_is_function(self): 170 | vr = ValidationRuleFormat('a', 1) 171 | validator = vr.generate_validator() 172 | self.assertIsInstance(validator, FunctionType) 173 | 174 | def test_generator_docstring(self): 175 | vr = ValidationRuleFormat('a', 1) 176 | validator = vr.generate_validator() 177 | self.assertEqual(validator.__doc__, 'a: 1') 178 | 179 | def test_generate_validator_str_valid(self): 180 | vr = ValidationRuleFormat('FORMAT', 'jpeg') 181 | validator = vr.generate_validator() 182 | self.assertIsNone(validator(self.img)) 183 | 184 | def test_generate_validator_str_invalid(self): 185 | vr = ValidationRuleFormat('FORMAT', 'bmp') 186 | validator = vr.generate_validator() 187 | with self.assertRaises(ValidationError): 188 | validator(self.img) 189 | 190 | def test_generate_validator_list_valid(self): 191 | vr = ValidationRuleFormat('FORMAT', ['bmp', 'webp', 'jpeg']) 192 | validator = vr.generate_validator() 193 | self.assertIsNone(validator(self.img)) 194 | 195 | def test_generate_validator_list_invalid(self): 196 | vr = ValidationRuleFormat('FORMAT', ['bmp', 'gif']) 197 | validator = vr.generate_validator() 198 | with self.assertRaises(ValidationError): 199 | validator(self.img) 200 | 201 | def test_generate_validator_dict_valid(self): 202 | valid_rules = [ 203 | ValidationRuleFormat('FORMAT', {'ne': 'webp'}), 204 | ValidationRuleFormat('FORMAT', {'ne': ['bmp', 'gif']}), 205 | ValidationRuleFormat('FORMAT', { 206 | 'ne': ['bmp', 'gif'], 207 | 'err': 'invalid format', 208 | }), 209 | ] 210 | for vr in valid_rules: 211 | with self.subTest(vr=vr): 212 | validator = vr.generate_validator() 213 | self.assertIsNone(validator(self.img)) 214 | 215 | def test_generate_validator_dict_invalid(self): 216 | invalid_rules = [ 217 | ValidationRuleFormat('FORMAT', {'ne': 'jpeg'}), 218 | ValidationRuleFormat('FORMAT', {'ne': ['webp', 'jpeg']}), 219 | ] 220 | for vr in invalid_rules: 221 | with self.subTest(vr=vr): 222 | validator = vr.generate_validator() 223 | with self.assertRaises(ValidationError): 224 | validator(self.img) 225 | 226 | def test_generate_validator_custom_error(self): 227 | vr = ValidationRuleFormat('FORMAT', { 228 | 'ne': 'jpeg', 229 | 'err': 'format error here!', 230 | }) 231 | validator = vr.generate_validator() 232 | err = 'format error here!' 233 | with self.assertRaisesMessage(ValidationError, err): 234 | validator(self.img) 235 | -------------------------------------------------------------------------------- /tests/test_suites/test_validation_rule_size.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from django.core.exceptions import ValidationError 6 | 7 | from vimage.core.validator_types import ValidationRuleSize 8 | from vimage.core.exceptions import InvalidValueError 9 | 10 | from .test_validation_rule_base import ValidatorTestCase 11 | from .const import dotted_path 12 | 13 | 14 | class ValidationRuleSizeTestCase(TestCase): 15 | def test_init(self): 16 | vr = ValidationRuleSize('SIZE', 100) 17 | self.assertEqual(vr.unit, 'KB') 18 | 19 | def test_humanize_rule(self): 20 | vr = ValidationRuleSize('SIZE', 100) 21 | self.assertEqual(vr.humanize_rule(), 'equal to 100KB') 22 | 23 | with patch(dotted_path('validator_types', 'ValidationRuleBase', 24 | 'format_humanized_rules')) as m: 25 | vr = ValidationRuleSize('SIZE', {'ne': 100}) 26 | vr.humanize_rule() 27 | self.assertTrue(m.is_called) 28 | 29 | def test_prettify_value(self): 30 | vr = ValidationRuleSize('SIZE', 100) 31 | self.assertEqual(vr.prettify_value(100), '100KB') 32 | self.assertEqual(vr.prettify_value('Hello'), 'HelloKB') 33 | 34 | @patch(dotted_path('validator_types', 'ValidationRuleSize', 35 | 'validate_operators')) 36 | def test_valid_dict_rule(self, patch_method): 37 | vr = ValidationRuleSize('SIZE', 100) 38 | vr.valid_dict_rule() 39 | self.assertTrue(patch_method.called) 40 | 41 | def test_is_valid__size_rule_type(self): 42 | """ 43 | "rule" should be either a positive int or a dict filled with proper 44 | key-value validation rules. 45 | """ 46 | vr = ValidationRuleSize('SIZE', '') 47 | err = f'The value of the rule "SIZE", "", ' \ 48 | f'should be either an int or a dict.' 49 | with self.assertRaisesMessage(InvalidValueError, err): 50 | vr.is_valid() 51 | 52 | vr = ValidationRuleSize('SIZE', []) 53 | err = f'The value of the rule "SIZE", "[]", ' \ 54 | f'should be either an int or a dict.' 55 | with self.assertRaisesMessage(InvalidValueError, err): 56 | vr.is_valid() 57 | 58 | vr = ValidationRuleSize('SIZE', ()) 59 | err = f'The value of the rule "SIZE", "()", ' \ 60 | f'should be either an int or a dict.' 61 | with self.assertRaisesMessage(InvalidValueError, err): 62 | vr.is_valid() 63 | 64 | def test_is_valid__size_rule_value(self): 65 | # invalid value (negative int) 66 | vr = ValidationRuleSize('SIZE', -1) 67 | err = f'The value of the rule "SIZE", "-1", ' \ 68 | f'should be a positive integer.' 69 | with self.assertRaisesMessage(InvalidValueError, err): 70 | vr.is_valid() 71 | 72 | # invalid value (empty dict) 73 | vr = ValidationRuleSize('SIZE', {}) 74 | err = f'The value of the rule "SIZE", "{{}}", ' \ 75 | f'should be a non-empty dict.' 76 | with self.assertRaisesMessage(InvalidValueError, err): 77 | vr.is_valid() 78 | 79 | # valid value (positive int) 80 | vr = ValidationRuleSize('SIZE', 1) 81 | self.assertIsNone(vr.is_valid()) 82 | 83 | # valid value (correct dict) 84 | vr = ValidationRuleSize('SIZE', {'gte': 100}) 85 | m_path = dotted_path('validator_types', 'ValidationRuleSize', 86 | 'valid_dict_rule') 87 | with patch(m_path) as m: 88 | vr.is_valid() 89 | self.assertTrue(m.called) 90 | 91 | # valid dict 92 | self.assertIsNone(vr.is_valid()) 93 | 94 | 95 | class ValidationRuleSizeValidatorTestCase(ValidatorTestCase): 96 | def test_generator_is_function(self): 97 | vr = ValidationRuleSize('a', 1) 98 | validator = vr.generate_validator() 99 | self.assertIsInstance(validator, FunctionType) 100 | 101 | def test_generator_docstring(self): 102 | vr = ValidationRuleSize('a', 1) 103 | validator = vr.generate_validator() 104 | self.assertEqual(validator.__doc__, 'a: 1') 105 | 106 | def test_generate_validator_int_valid(self): 107 | # "validator" is a function 108 | vr = ValidationRuleSize('SIZE', 100) 109 | validator = vr.generate_validator() 110 | self.assertIsNone(validator(self.img)) 111 | 112 | def test_generate_validator_int_invalid(self): 113 | invalid_rules = [ 114 | ValidationRuleSize('SIZE', 50), 115 | ValidationRuleSize('SIZE', -1) 116 | ] 117 | for vr in invalid_rules: 118 | with self.subTest(vr=vr): 119 | validator = vr.generate_validator() 120 | with self.assertRaises(ValidationError): 121 | validator(self.img) 122 | 123 | def test_generate_validator_dict_valid(self): 124 | valid_rules = [ 125 | ValidationRuleSize('SIZE', {'gte': 50}), 126 | ValidationRuleSize('SIZE', {'lt': 200}), 127 | ValidationRuleSize('SIZE', {'lte': 200}), 128 | ValidationRuleSize('SIZE', {'gte': 50, 'lt': 200}), 129 | ValidationRuleSize('SIZE', {'gte': 50, 'lt': 200, 'ne': 300}), 130 | ValidationRuleSize('SIZE', {'ne': 300}), 131 | ValidationRuleSize('SIZE', {'eq': 100}), 132 | ] 133 | for vr in valid_rules: 134 | with self.subTest(vr=vr): 135 | validator = vr.generate_validator() 136 | self.assertIsNone(validator(self.img)) 137 | 138 | def test_generate_validator_dict_invalid(self): 139 | invalid_rules = [ 140 | ValidationRuleSize('SIZE', {'gte': 150}), 141 | ValidationRuleSize('SIZE', {'lt': 100}), 142 | ValidationRuleSize('SIZE', {'lte': 99}), 143 | ValidationRuleSize('SIZE', {'gte': 150, 'lte': 100}), 144 | ValidationRuleSize('SIZE', {'gte': 99, 'lt': 100, 'ne': 300}), 145 | ValidationRuleSize('SIZE', {'ne': 100}), 146 | ValidationRuleSize('SIZE', {'eq': 200}), 147 | ] 148 | for vr in invalid_rules: 149 | with self.subTest(vr=vr): 150 | validator = vr.generate_validator() 151 | with self.assertRaises(ValidationError): 152 | validator(self.img) 153 | 154 | def test_generate_validator_custom_error(self): 155 | vr = ValidationRuleSize('SIZE', { 156 | 'lt': 50, 157 | 'err': 'size error here!', 158 | }) 159 | validator = vr.generate_validator() 160 | err = 'size error here!' 161 | with self.assertRaisesMessage(ValidationError, err): 162 | validator(self.img) 163 | -------------------------------------------------------------------------------- /tests/test_suites/test_vimage_config.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from types import GeneratorType 3 | 4 | from django.test import TestCase 5 | 6 | from vimage.core.base import VimageEntry, VimageConfig 7 | 8 | from tests.apps.myapp import models as my_app_models 9 | from .const import dotted_path 10 | 11 | 12 | class VimageConfigTestCase(TestCase): 13 | def test_entry(self): 14 | vc = VimageConfig({}) 15 | self.assertEqual(vc.config, {}) 16 | 17 | def test_str(self): 18 | vc = VimageConfig({}) 19 | self.assertEqual(str(vc), '{}') 20 | 21 | def test_repr(self): 22 | vc = VimageConfig({}) 23 | self.assertEqual(repr(vc), 'VimageConfig({})') 24 | self.assertIsInstance(eval(repr(vc)), VimageConfig) 25 | 26 | def test_vimage_entry_generator(self): 27 | vc = VimageConfig({ 28 | 'myapp.models': { 29 | 'SIZE': 900 30 | }, 31 | }) 32 | gen = vc.vimage_entry_generator() 33 | 34 | # Returned value is a generator 35 | self.assertIsInstance(gen, GeneratorType) 36 | # First value is a VimageEntry object 37 | self.assertIsInstance(next(gen), VimageEntry) 38 | # No more values! 39 | with self.assertRaises(StopIteration): 40 | next(gen) 41 | 42 | def test_build_info(self): 43 | vc = VimageConfig({ 44 | 'myapp.models': { 45 | 'SIZE': 900, 46 | }, 47 | 'myapp.models.MyModel': { 48 | 'SIZE': 500, 49 | }, 50 | 'myapp2.models': { 51 | 'DIMENSIONS': (100, 100), 52 | }, 53 | }) 54 | info = vc.build_info() 55 | # "info" is a dict 56 | self.assertIsInstance(info, dict) 57 | # "info" has keys equal to the number of the app_labels declared 58 | self.assertEqual(sorted(info.keys()), ['myapp', 'myapp2']) 59 | # each value of "info" is a list 60 | self.assertIsInstance(info['myapp'], list) 61 | self.assertIsInstance(info['myapp2'], list) 62 | # The value of the key "myapp" contains 2 elements 63 | self.assertEqual(len(info['myapp']), 2) 64 | # Each of those elements is a dict with the following keys 65 | self.assertEqual( 66 | sorted(info['myapp'][0].keys()), 67 | ['app_label', 'fields', 'mapping', 'specificity'] 68 | ) 69 | 70 | def test_sort_info(self): 71 | orig_info = { 72 | 'myapp': 73 | [ 74 | { 75 | 'app_label': 'myapp', 76 | 'fields': ['img3'], 77 | 'specificity': 3, 78 | 'mapping': {}, 79 | }, 80 | { 81 | 'app_label': 'myapp', 82 | 'fields': ['img1', 'img3'], 83 | 'specificity': 2, 84 | 'mapping': {}, 85 | }, 86 | { 87 | 'app_label': 'myapp', 88 | 'fields': ['img1', 'img2', 'img3'], 89 | 'specificity': 1, 90 | 'mapping': {}, 91 | }, 92 | ], 93 | } 94 | expected_info = { 95 | 'myapp': 96 | [ 97 | { 98 | 'app_label': 'myapp', 99 | 'fields': ['img1', 'img2', 'img3'], 100 | 'specificity': 1, 101 | 'mapping': {}, 102 | }, 103 | { 104 | 'app_label': 'myapp', 105 | 'fields': ['img1', 'img3'], 106 | 'specificity': 2, 107 | 'mapping': {}, 108 | }, 109 | { 110 | 'app_label': 'myapp', 111 | 'fields': ['img3'], 112 | 'specificity': 3, 113 | 'mapping': {}, 114 | }, 115 | ], 116 | } 117 | sorted_info = VimageConfig.sort_info(orig_info) 118 | self.assertDictEqual(sorted_info, expected_info) 119 | 120 | def test_build_draft_registry_1(self): 121 | info = { 122 | 'myapp': 123 | [ 124 | { 125 | 'app_label': 'myapp', 126 | 'fields': ['img1', 'img2', 'img3'], 127 | 'specificity': 1, 128 | 'mapping': { 129 | 'SIZE': '100', 130 | 'DIMENSIONS': '100x100', 131 | } 132 | }, 133 | { 134 | 'app_label': 'myapp', 135 | 'fields': ['img1', 'img3'], 136 | 'specificity': 2, 137 | 'mapping': { 138 | 'FORMAT': 'png', 139 | } 140 | }, 141 | { 142 | 'app_label': 'myapp', 143 | 'fields': ['img3'], 144 | 'specificity': 3, 145 | 'mapping': { 146 | 'SIZE': '500', 147 | 'DIMENSIONS': '500x640', 148 | 'FORMAT': 'jpeg', 149 | } 150 | }, 151 | ], 152 | } 153 | expected_draft_registry = { 154 | 'img1': {'SIZE': '100', 'DIMENSIONS': '100x100', 'FORMAT': 'png'}, 155 | 'img2': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 156 | 'img3': {'SIZE': '500', 'DIMENSIONS': '500x640', 'FORMAT': 'jpeg'}, 157 | } 158 | draft_registry = VimageConfig.build_draft_registry(info) 159 | self.assertDictEqual(draft_registry, expected_draft_registry) 160 | 161 | def test_build_draft_registry_2(self): 162 | info = { 163 | 'myapp': 164 | [ 165 | { 166 | 'app_label': 'myapp', 167 | 'fields': ['img1', 'img2', 'img3'], 168 | 'specificity': 1, 169 | 'mapping': { 170 | 'SIZE': '100', 171 | 'DIMENSIONS': '100x100', 172 | } 173 | }, 174 | { 175 | 'app_label': 'myapp', 176 | 'fields': ['img3'], 177 | 'specificity': 3, 178 | 'mapping': { 179 | 'SIZE': '500', 180 | 'DIMENSIONS': '500x640', 181 | 'FORMAT': 'jpeg', 182 | } 183 | }, 184 | ], 185 | } 186 | expected_draft_registry = { 187 | 'img1': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 188 | 'img2': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 189 | 'img3': {'SIZE': '500', 'DIMENSIONS': '500x640', 'FORMAT': 'jpeg'}, 190 | } 191 | draft_registry = VimageConfig.build_draft_registry(info) 192 | self.assertDictEqual(draft_registry, expected_draft_registry) 193 | 194 | def test_build_draft_registry_3(self): 195 | info = { 196 | 'myapp': 197 | [ 198 | { 199 | 'app_label': 'myapp', 200 | 'fields': ['img1', 'img2'], 201 | 'specificity': 2, 202 | 'mapping': { 203 | 'FORMAT': 'png', 204 | } 205 | }, 206 | { 207 | 'app_label': 'myapp', 208 | 'fields': ['img3', 'img4'], 209 | 'specificity': 2, 210 | 'mapping': { 211 | 'SIZE': '500', 212 | 'DIMENSIONS': '500x640', 213 | 'FORMAT': 'jpeg', 214 | } 215 | }, 216 | ], 217 | } 218 | expected_draft_registry = { 219 | 'img1': {'FORMAT': 'png'}, 220 | 'img2': {'FORMAT': 'png'}, 221 | 'img3': {'SIZE': '500', 'DIMENSIONS': '500x640', 'FORMAT': 'jpeg'}, 222 | 'img4': {'SIZE': '500', 'DIMENSIONS': '500x640', 'FORMAT': 'jpeg'}, 223 | } 224 | draft_registry = VimageConfig.build_draft_registry(info) 225 | self.assertDictEqual(draft_registry, expected_draft_registry) 226 | 227 | def test_build_draft_registry_4(self): 228 | info = { 229 | 'myapp1': 230 | [ 231 | { 232 | 'app_label': 'myapp', 233 | 'fields': ['img1', 'img2', 'img3'], 234 | 'specificity': 1, 235 | 'mapping': { 236 | 'SIZE': '100', 237 | 'DIMENSIONS': '100x100', 238 | } 239 | }, 240 | ], 241 | 'myapp2': 242 | [ 243 | { 244 | 'app_label': 'myapp', 245 | 'fields': ['img4', 'img5', 'img6'], 246 | 'specificity': 1, 247 | 'mapping': { 248 | 'DIMENSIONS': '666x666', # :) 249 | } 250 | }, 251 | ], 252 | } 253 | expected_draft_registry = { 254 | 'img1': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 255 | 'img2': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 256 | 'img3': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 257 | 'img4': {'DIMENSIONS': '666x666'}, 258 | 'img5': {'DIMENSIONS': '666x666'}, 259 | 'img6': {'DIMENSIONS': '666x666'}, 260 | } 261 | draft_registry = VimageConfig.build_draft_registry(info) 262 | self.assertDictEqual(draft_registry, expected_draft_registry) 263 | 264 | def test_build_registry_1(self): 265 | draft_registry = { 266 | 'img1': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 267 | 'img2': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 268 | 'img3': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 269 | 'img4': {'DIMENSIONS': '666x666'}, 270 | 'img5': {'DIMENSIONS': '666x666'}, 271 | 'img6': {'DIMENSIONS': '666x666'}, 272 | } 273 | expected_registry = { 274 | 'img1': ['100', '100x100'], 275 | 'img2': ['100', '100x100'], 276 | 'img3': ['100', '100x100'], 277 | 'img4': ['666x666'], 278 | 'img5': ['666x666'], 279 | 'img6': ['666x666'], 280 | } 281 | registry = VimageConfig.build_registry(draft_registry) 282 | self.assertDictEqual(registry, expected_registry) 283 | 284 | def test_build_registry_2(self): 285 | draft_registry = { 286 | 'img1': {'SIZE': '100', 'DIMENSIONS': '100x100', 'FORMAT': 'png'}, 287 | 'img2': {'SIZE': '100', 'DIMENSIONS': '100x100'}, 288 | 'img3': {'SIZE': '500', 'DIMENSIONS': '500x640', 'FORMAT': 'jpeg'}, 289 | } 290 | expected_registry = { 291 | 'img1': ['100', '100x100', 'png'], 292 | 'img2': ['100', '100x100'], 293 | 'img3': ['500', '500x640', 'jpeg'], 294 | } 295 | registry = VimageConfig.build_registry(draft_registry) 296 | self.assertDictEqual(registry, expected_registry) 297 | 298 | def test_add_validators_methods_called(self): 299 | m_path = dotted_path('base', 'VimageConfig', 'build_info') 300 | with patch(m_path) as m: 301 | VimageConfig({'myapp': {}}).add_validators() 302 | self.assertTrue(m.called) 303 | 304 | m_path = dotted_path('base', 'VimageConfig', 'sort_info') 305 | with patch(m_path) as m: 306 | VimageConfig({'myapp': {}}).add_validators() 307 | self.assertTrue(m.called) 308 | 309 | m_path = dotted_path('base', 'VimageConfig', 'build_draft_registry') 310 | with patch(m_path) as m: 311 | VimageConfig({'myapp': {}}).add_validators() 312 | self.assertTrue(m.called) 313 | 314 | m_path = dotted_path('base', 'VimageConfig', 'build_registry') 315 | with patch(m_path) as m: 316 | VimageConfig({'myapp': {}}).add_validators() 317 | self.assertTrue(m.called) 318 | 319 | 320 | class VimageConfigAddValidatorsTestCase(TestCase): 321 | def setUp(self): 322 | self.img = my_app_models.MyModel._meta.get_field('img') 323 | self.img.validators = [] 324 | 325 | def test_add_validators_size(self): 326 | vc = VimageConfig({ 327 | 'myapp.models.MyModel.img': { 328 | 'SIZE': 1000, 329 | } 330 | }) 331 | vc.add_validators() 332 | self.assertEqual(len(self.img.validators), 1) 333 | self.assertIn( 334 | 'ValidationRuleSize.generate_validator', 335 | str(self.img.validators[0]) 336 | ) 337 | 338 | def test_add_validators_dimensions(self): 339 | vc = VimageConfig({ 340 | 'myapp.models.MyModel.img': { 341 | 'DIMENSIONS': (1000, 1000), 342 | } 343 | }) 344 | vc.add_validators() 345 | self.assertEqual(len(self.img.validators), 1) 346 | self.assertIn( 347 | 'ValidationRuleDimensions.generate_validator', 348 | str(self.img.validators[0]) 349 | ) 350 | 351 | def test_add_validators_format(self): 352 | vc = VimageConfig({ 353 | 'myapp.models.MyModel.img': { 354 | 'FORMAT': 'png', 355 | } 356 | }) 357 | vc.add_validators() 358 | self.assertEqual(len(self.img.validators), 1) 359 | self.assertIn( 360 | 'ValidationRuleFormat.generate_validator', 361 | str(self.img.validators[0]) 362 | ) 363 | 364 | def test_add_validators_aspect_ratio(self): 365 | vc = VimageConfig({ 366 | 'myapp.models.MyModel.img': { 367 | 'ASPECT_RATIO': 1, 368 | } 369 | }) 370 | vc.add_validators() 371 | self.assertEqual(len(self.img.validators), 1) 372 | self.assertIn( 373 | 'ValidationRuleAspectRatio.generate_validator', 374 | str(self.img.validators[0]) 375 | ) 376 | 377 | def test_add_validators_multiple(self): 378 | vc = VimageConfig({ 379 | 'myapp.models.MyModel.img': { 380 | 'SIZE': 1000, 381 | 'DIMENSIONS': (1000, 1000), 382 | 'FORMAT': 'jpeg', 383 | } 384 | }) 385 | vc.add_validators() 386 | self.assertEqual(len(self.img.validators), 3) 387 | -------------------------------------------------------------------------------- /tests/test_suites/test_vimage_entry.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from vimage.core.base import VimageKey, VimageValue, VimageEntry 6 | 7 | from .const import dotted_path 8 | 9 | 10 | class VimageEntryTestCase(TestCase): 11 | def test_entry(self): 12 | ve = VimageEntry('app', {}) 13 | self.assertIsInstance(ve.key, VimageKey) 14 | self.assertEqual(ve.key.key, VimageKey('app').key) 15 | self.assertIsInstance(ve.value, VimageValue) 16 | self.assertEqual(ve.value.value, VimageValue({}).value) 17 | 18 | def test_str(self): 19 | ve = VimageEntry('app', {}) 20 | self.assertEqual(str(ve), 'app: {}') 21 | 22 | def test_repr(self): 23 | ve = VimageEntry('app', {}) 24 | self.assertEqual( 25 | repr(ve), 26 | "VimageEntry('app', {})" 27 | ) 28 | self.assertIsInstance(eval(repr(ve)), VimageEntry) 29 | 30 | def test_is_valid(self): 31 | m_path = dotted_path('base', 'VimageKey', 'is_valid') 32 | with patch(m_path) as m: 33 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 34 | ve.is_valid() 35 | self.assertTrue(m.called) 36 | 37 | m_path = dotted_path('base', 'VimageValue', 'is_valid') 38 | with patch(m_path) as m: 39 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 40 | ve.is_valid() 41 | self.assertTrue(m.called) 42 | 43 | def test_app_label(self): 44 | m_path = dotted_path('base', 'VimageKey', 'get_app_label') 45 | with patch(m_path) as m: 46 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 47 | label = ve.app_label 48 | self.assertTrue(m.called) 49 | 50 | def test_fields(self): 51 | m_path = dotted_path('base', 'VimageKey', 'get_fields') 52 | with patch(m_path) as m: 53 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 54 | sp = ve.fields 55 | self.assertTrue(m.called) 56 | 57 | def test_specificity(self): 58 | m_path = dotted_path('base', 'VimageKey', 'get_specificity') 59 | with patch(m_path) as m: 60 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 61 | sp = ve.specificity 62 | self.assertTrue(m.called) 63 | 64 | def test_mapping(self): 65 | m_path = dotted_path('base', 'VimageValue', 'type_validator_mapping') 66 | with patch(m_path) as m: 67 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 68 | sp = ve.mapping 69 | self.assertTrue(m.called) 70 | 71 | def test_entry_info(self): 72 | ve = VimageEntry('myapp.models', {'SIZE': 10}) 73 | self.assertEqual( 74 | sorted(ve.entry_info.keys()), 75 | ['app_label', 'fields', 'mapping', 'specificity'] 76 | ) 77 | -------------------------------------------------------------------------------- /tests/test_suites/test_vimage_key.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from tests.apps.myapp import models as my_app_models 4 | 5 | from vimage.core.base import VimageKey 6 | from vimage.core.exceptions import InvalidKeyError 7 | 8 | 9 | def all_image_fields(): 10 | return [ 11 | my_app_models.MyModel._meta.get_field('img'), 12 | my_app_models.AnotherModel._meta.get_field('thumb'), 13 | my_app_models.AnotherModel._meta.get_field('image'), 14 | my_app_models.GreatModel._meta.get_field('picture'), 15 | my_app_models.GreatModel._meta.get_field('large_img') 16 | ] 17 | 18 | 19 | def another_model_image_fields(): 20 | return [ 21 | my_app_models.AnotherModel._meta.get_field('thumb'), 22 | my_app_models.AnotherModel._meta.get_field('image') 23 | ] 24 | 25 | 26 | def specific_image(): 27 | return [my_app_models.MyModel._meta.get_field('img')] 28 | 29 | 30 | class VimageKeyTestCase(TestCase): 31 | def test_key(self): 32 | vk = VimageKey('app') 33 | self.assertEqual(vk.key, 'app') 34 | err = f'Each VIMAGE dict key should be a ' \ 35 | f' type. Current key: "1", is !' 36 | with self.assertRaisesMessage(TypeError, err): 37 | VimageKey(1) 38 | 39 | def test_str(self): 40 | vk = VimageKey('app') 41 | self.assertEqual(str(vk), 'app') 42 | 43 | def test_repr(self): 44 | vk = VimageKey('app') 45 | self.assertEqual(repr(vk), "VimageKey('app')") 46 | self.assertIsInstance(eval(repr(vk)), VimageKey) 47 | 48 | def test_split_key(self): 49 | vk = VimageKey('myapp.models.MyModel') 50 | self.assertListEqual(vk.split_key(), ['myapp', 'models', 'MyModel']) 51 | vk = VimageKey('') 52 | self.assertListEqual(vk.split_key(), ['']) 53 | 54 | def test_models_in_key(self): 55 | vk = VimageKey('myapp.models') 56 | self.assertTrue(vk.models_in_key(vk.split_key())) 57 | vk = VimageKey('myapp.models.MyModel') 58 | self.assertTrue(vk.models_in_key(vk.split_key())) 59 | vk = VimageKey('myapp') 60 | self.assertFalse(vk.models_in_key(vk.split_key())) 61 | 62 | def test_valid_key_length(self): 63 | valid_keys = [ 64 | VimageKey('.'.join(['a'] * 2)), # 'a.a', 65 | VimageKey('.'.join(['a'] * 3)), # 'a.a.a' 66 | VimageKey('.'.join(['a'] * 4)), # 'a.a.a.a' 67 | ] 68 | for vk in valid_keys: 69 | with self.subTest(vk=vk): 70 | self.assertTrue(vk.valid_key_length(vk.split_key())) 71 | 72 | invalid_keys = [ 73 | VimageKey(''), 74 | VimageKey('a'), 75 | VimageKey('.'.join(['a'] * 5)), # 'a.a.a.a.a', 76 | ] 77 | for vk in invalid_keys: 78 | with self.subTest(vk=vk): 79 | self.assertFalse(vk.valid_key_length(vk.split_key())) 80 | 81 | def test_key_non_empty_str(self): 82 | vk = VimageKey('') 83 | self.assertFalse(vk.key_non_empty_str()) 84 | vk = VimageKey('myapp') 85 | self.assertTrue(vk.key_non_empty_str()) 86 | 87 | def test_validate_dotted_key(self): 88 | # At least the "models" word is required 89 | vk = VimageKey('myapp') 90 | err = f'[myapp]: The key must consists of two to four words, ' \ 91 | f'separated by dot. It must be a path to one of ' \ 92 | f'the following: the "models" module, ' \ 93 | f'a Django Model class or a Django ImageField field.' 94 | with self.assertRaisesMessage(InvalidKeyError, err): 95 | vk.validate_dotted_key() 96 | 97 | vk = VimageKey('myapp.MyModel') 98 | err = f'[myapp.MyModel]: The second word of the key, should be ' \ 99 | f'"models", not "MyModel"!' 100 | with self.assertRaisesMessage(InvalidKeyError, err): 101 | vk.validate_dotted_key() 102 | 103 | # Non-valid app label 104 | vk = VimageKey('nonexistapp.models.MyModel') 105 | err = f'[nonexistapp.models.MyModel]: The app "nonexistapp" is ' \ 106 | f'either not in "INSTALLED_APPS" or it does not exist!' 107 | with self.assertRaisesMessage(InvalidKeyError, err): 108 | vk.validate_dotted_key() 109 | 110 | # App without a "models" module 111 | vk = VimageKey('no_model.models.MyModel') 112 | err = f'[no_model.models.MyModel]: The app "no_model" has no ' \ 113 | f'"models" module defined. Are you sure it exists?' 114 | with self.assertRaisesMessage(InvalidKeyError, err): 115 | vk.validate_dotted_key() 116 | 117 | # A valid app with "models" module 118 | vk = VimageKey('myapp.models') 119 | self.assertIsNone(vk.validate_dotted_key()) 120 | 121 | # A valid app with a non-valid model 122 | vk = VimageKey('myapp.models.NonExistModel') 123 | err = f'[myapp.models.NonExistModel]: The model "NonExistModel" ' \ 124 | f'does not exist! ' \ 125 | f'Available model names: "MyModel, AnotherModel, GreatModel".' 126 | with self.assertRaisesMessage(InvalidKeyError, err): 127 | vk.validate_dotted_key() 128 | 129 | # A valid app, with "models" module, but non-valid ImageField 130 | vk = VimageKey('myapp2.models.Hello.image') 131 | err = f'[myapp2.models.Hello.image]: The field "image" does not ' \ 132 | f'exist! Available ImageField names: "img".' 133 | with self.assertRaisesMessage(InvalidKeyError, err): 134 | vk.validate_dotted_key() 135 | 136 | # A perfectly valid app! 137 | vk = VimageKey('myapp.models.MyModel.img') 138 | self.assertIsNone(vk.validate_dotted_key()) 139 | 140 | def test_key_valid_dotted_format(self): 141 | invalid_keys = [ 142 | VimageKey(''), 143 | VimageKey('myapp.class'), 144 | VimageKey('myapp.def'), 145 | ] 146 | for vk in invalid_keys: 147 | with self.subTest(vk=vk): 148 | self.assertFalse(vk.key_valid_dotted_format()) 149 | 150 | valid_keys = [ 151 | VimageKey('myapp'), 152 | VimageKey('myapp.something.other'), 153 | ] 154 | for vk in valid_keys: 155 | with self.subTest(vk=vk): 156 | self.assertTrue(vk.key_valid_dotted_format()) 157 | 158 | def test_validate_key(self): 159 | vk = VimageKey('myapp.models.MyModel') 160 | self.assertIsNone(vk.validate_key()) 161 | 162 | invalid_keys = [ 163 | VimageKey('just plain text!'), 164 | VimageKey('myapp,models,MyModel'), 165 | VimageKey('myapp.MyModel,img'), 166 | VimageKey('myapp.MyModel..img'), 167 | VimageKey('.myapp.MyModel.img'), # leading dot 168 | VimageKey('myapp.MyModel.img.'), # trailing dot 169 | ] 170 | for vk in invalid_keys: 171 | with self.subTest(vk=vk): 172 | err = f'The key "{vk.key}" is not a valid python dotted ' \ 173 | f'path (words separated with the "." dot character). ' \ 174 | f'Please check for any typos!' 175 | with self.assertRaisesMessage(InvalidKeyError, err): 176 | vk.validate_key() 177 | 178 | def test_get_app_img_fields(self): 179 | vk = VimageKey('myapp.models') 180 | self.assertListEqual(vk.get_app_img_fields(), all_image_fields()) 181 | 182 | def test_get_specific_model_img_fields(self): 183 | vk = VimageKey('myapp.models.AnotherModel') 184 | 185 | self.assertListEqual( 186 | vk.get_specific_model_img_fields(), 187 | another_model_image_fields() 188 | ) 189 | 190 | def test_get_img_field(self): 191 | vk = VimageKey('myapp.models.MyModel.img') 192 | self.assertListEqual(vk.get_img_field(), specific_image()) 193 | 194 | def test_get_specificity(self): 195 | vk = VimageKey('myapp') 196 | self.assertEqual(vk.get_specificity(), 0) 197 | 198 | vk = VimageKey('myapp.models') 199 | self.assertEqual(vk.get_specificity(), 1) 200 | 201 | vk = VimageKey('myapp.models.MyModel') 202 | self.assertEqual(vk.get_specificity(), 2) 203 | 204 | vk = VimageKey('myapp.models.MyModel.img') 205 | self.assertEqual(vk.get_specificity(), 3) 206 | 207 | def test_get_app_label(self): 208 | vk = VimageKey('myapp.models') 209 | self.assertEqual(vk.get_app_label(), 'myapp') 210 | 211 | def test_get_fields(self): 212 | vk1 = VimageKey('myapp') 213 | vk2 = VimageKey('myapp.models.MyModel.img.other') 214 | 215 | vk3 = VimageKey('myapp.models') 216 | vk4 = VimageKey('myapp.models.AnotherModel') 217 | vk5 = VimageKey('myapp.models.MyModel.img') 218 | self.assertListEqual(vk1.get_fields(), []) 219 | self.assertListEqual(vk2.get_fields(), []) 220 | 221 | self.assertListEqual(vk3.get_fields(), all_image_fields()) 222 | self.assertListEqual(vk4.get_fields(), another_model_image_fields()) 223 | self.assertListEqual(vk5.get_fields(), specific_image()) 224 | 225 | def test_is_valid(self): 226 | vk = VimageKey('') 227 | err = f'The key "" should be a non-empty string. It ' \ 228 | f'must be the dotted path to the app\'s "models" module ' \ 229 | f'or a "Model" class or an "ImageField" field.' 230 | with self.assertRaisesMessage(InvalidKeyError, err): 231 | vk.is_valid() 232 | 233 | valid_keys = [ 234 | VimageKey('myapp.models'), 235 | VimageKey('myapp.models.MyModel'), 236 | VimageKey('myapp.models.MyModel.img'), 237 | ] 238 | for vk in valid_keys: 239 | with self.subTest(vk=vk): 240 | self.assertIsNone(vk.is_valid()) 241 | -------------------------------------------------------------------------------- /tests/test_suites/test_vimage_value.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from types import GeneratorType 3 | 4 | from django.test import TestCase 5 | 6 | from vimage.core import validator_types 7 | from vimage.core.base import VimageValue 8 | from vimage.core.exceptions import InvalidValueError 9 | 10 | from .const import CORE, dotted_path 11 | 12 | 13 | class VimageValueTestCase(TestCase): 14 | def test_value(self): 15 | vv = VimageValue({}) 16 | self.assertEqual(vv.value, {}) 17 | err = f'Each VIMAGE dict value should be a ' \ 18 | f' type. Current value: "1", is !' 19 | with self.assertRaisesMessage(TypeError, err): 20 | VimageValue(1) 21 | 22 | def test_str(self): 23 | vv = VimageValue({}) 24 | self.assertEqual(str(vv), '{}') 25 | vv = VimageValue({'SIZE': 100}) 26 | self.assertEqual(str(vv), "{'SIZE': 100}") 27 | 28 | def test_repr(self): 29 | vv = VimageValue({}) 30 | self.assertEqual(repr(vv), 'VimageValue({})') 31 | self.assertIsInstance(eval(repr(vv)), VimageValue) 32 | 33 | vv = VimageValue({'SIZE': 100}) 34 | self.assertEqual(repr(vv), 'VimageValue({\'SIZE\': 100})') 35 | self.assertIsInstance(eval(repr(vv)), VimageValue) 36 | 37 | def test_validation_rule_generator(self): 38 | # A generator is returned 39 | v = VimageValue({'SIZE': 1}) 40 | self.assertIsInstance(v.validation_rule_generator(), GeneratorType) 41 | 42 | # Size ValidationRule 43 | size_vv = VimageValue({'SIZE': {}}) 44 | size_vr = next(size_vv.validation_rule_generator()) 45 | self.assertIsInstance(size_vr, validator_types.ValidationRuleSize) 46 | 47 | # Dimensions ValidationRule 48 | dimensions_vv = VimageValue({'DIMENSIONS': {}}) 49 | dimensions_vr = next(dimensions_vv.validation_rule_generator()) 50 | self.assertIsInstance( 51 | dimensions_vr, 52 | validator_types.ValidationRuleDimensions 53 | ) 54 | 55 | # Format ValidationRule 56 | format_vv = VimageValue({'FORMAT': {}}) 57 | format_vr = next(format_vv.validation_rule_generator()) 58 | self.assertIsInstance(format_vr, validator_types.ValidationRuleFormat) 59 | 60 | # Aspect Ratio ValidationRule 61 | ratio_vv = VimageValue({'ASPECT_RATIO': {}}) 62 | ratio_vr = next(ratio_vv.validation_rule_generator()) 63 | self.assertIsInstance( 64 | ratio_vr, 65 | validator_types.ValidationRuleAspectRatio 66 | ) 67 | 68 | def test_validate_value(self): 69 | """ Test that is_valid() method is called """ 70 | mappings = { 71 | 'SIZE': 'ValidationRuleSize', 72 | 'DIMENSIONS': 'ValidationRuleDimensions', 73 | 'FORMAT': 'ValidationRuleFormat', 74 | 'ASPECT_RATIO': 'ValidationRuleAspectRatio', 75 | } 76 | for name, class_name in mappings.items(): 77 | const_path = [CORE, 'validator_types', 'is_valid'] 78 | # Insert the "class_name" before the "is_valid" element 79 | const_path.insert(-1, class_name) 80 | with patch('.'.join(const_path)) as m: 81 | vv = VimageValue({name: {}}) 82 | vv.validate_value() 83 | self.assertTrue(m.called) 84 | 85 | def test_nonsense_keys_together(self): 86 | vv = VimageValue({'SIZE': 15}) 87 | self.assertIsNone(vv.nonsense_keys_together()) 88 | 89 | vv = VimageValue({'DIMENSIONS': 15, 'ASPECT_RATIO': 1}) 90 | with self.assertRaises(InvalidValueError): 91 | vv.nonsense_keys_together() 92 | 93 | def test_type_validator_mapping(self): 94 | vv = VimageValue({'SIZE': 15}) 95 | self.assertIsInstance(vv.type_validator_mapping(), dict) 96 | 97 | def test_is_valid(self): 98 | vv = VimageValue({}) 99 | err = f'The value "{{}}" should be a non-empty dict ' \ 100 | f'with the proper validation rules. ' \ 101 | f'Please check the documentation for more information.' 102 | with self.assertRaisesMessage(InvalidValueError, err): 103 | vv.is_valid() 104 | 105 | value = { 106 | 'DIMENSIONS': (100, 100), 107 | 'ASPECT_RATIO': 1, 108 | } 109 | vv = VimageValue(value) 110 | with patch(dotted_path('base', 'VimageValue', 111 | 'nonsense_keys_together')) as m: 112 | with self.assertRaises(InvalidValueError): 113 | vv.is_valid() 114 | self.assertTrue(m.called) 115 | 116 | vv = VimageValue({'FORMAT': 'jpeg'}) 117 | m_path = dotted_path('base', 'VimageValue', 'validate_value') 118 | with patch(m_path) as m: 119 | vv.is_valid() 120 | self.assertTrue(m.called) 121 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py36-{django2,django3} 4 | py310-{django3,django4} 5 | 6 | [testenv] 7 | setenv = 8 | PYTHONPATH = {toxinidir}:{toxinidir}/vimage 9 | commands = coverage run --source vimage runtests.py 10 | deps = 11 | django2: Django>=2,<3 12 | django3: Django>=3,<4 13 | django4: Django==4.0.5 14 | -r{toxinidir}/requirements_test.txt 15 | basepython = 16 | py36: python3.6 17 | py310: python3.10 18 | -------------------------------------------------------------------------------- /vimage/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.2.0' 2 | -------------------------------------------------------------------------------- /vimage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VimageConfig(AppConfig): 5 | name = 'vimage' 6 | verbose_name = 'Django image validation' 7 | 8 | def ready(self): 9 | from vimage.core.checker import configuration_check 10 | # check configuration setting before adding any validators 11 | configuration_check() # raises Exception or None 12 | 13 | # proceed to validator(s) addition 14 | from vimage.core import add_validators 15 | add_validators() # raises Exception or None 16 | -------------------------------------------------------------------------------- /vimage/core/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from .base import VimageConfig 4 | from .const import CONFIG_NAME 5 | 6 | 7 | def add_validators(): 8 | """ 9 | Assumes that the VIMAGE setting has been checked for any errors. 10 | This is the main function that will initiate validation rules addition 11 | to each ImageField's 'validators' attribute. 12 | :return: None 13 | """ 14 | vc = VimageConfig(getattr(settings, CONFIG_NAME)) 15 | vc.add_validators() 16 | -------------------------------------------------------------------------------- /vimage/core/checker.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from .const import APP_NAME, CONFIG_NAME 4 | from .base import VimageEntry 5 | from . import exceptions 6 | 7 | 8 | def configuration_check(): 9 | """ 10 | Scans the ``VIMAGE`` dict setting for syntactic errors. 11 | 12 | :return: None or raises an exception from .exceptions 13 | """ 14 | 15 | # 1. Is configuration defined? 16 | if not hasattr(settings, CONFIG_NAME): 17 | err = f'"{APP_NAME}" is in INSTALLED_APPS but has not been ' \ 18 | f'configured! Either add "{CONFIG_NAME}" dict to your ' \ 19 | f'settings or remove "{APP_NAME}" from INSTALLED_APPS.' 20 | raise exceptions.MissingConfigError(err) 21 | 22 | config = getattr(settings, CONFIG_NAME) 23 | 24 | # 2. Is configuration a dict? 25 | if not isinstance(config, dict): 26 | error = f'"{CONFIG_NAME}" type is not a dictionary. The value ' \ 27 | f'should be a non-empty dict!' 28 | raise exceptions.InvalidConfigValueError(error) 29 | 30 | # 3. Is configuration a non-empty dict? 31 | if config == {}: 32 | error = f'"{CONFIG_NAME}" configuration is an empty dict! ' \ 33 | f'Add validation rules inside the "{CONFIG_NAME}" dict.' 34 | raise exceptions.EmptyConfigError(error) 35 | 36 | # 4. Is each key-value pair valid? 37 | for key, value in config.items(): 38 | VimageEntry(key, value).is_valid() 39 | -------------------------------------------------------------------------------- /vimage/core/const.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from vimage.apps import VimageConfig 6 | 7 | 8 | APP_NAME = VimageConfig.name 9 | CONFIG_NAME = 'VIMAGE' 10 | 11 | type_size = 'SIZE' 12 | type_dimensions = 'DIMENSIONS' 13 | type_format = 'FORMAT' 14 | type_aspect_ratio = 'ASPECT_RATIO' 15 | type_mode = 'MODE' # TODO: Add a MODE validation? 16 | 17 | valid_types_strings = [ 18 | type_size, type_dimensions, type_format, type_aspect_ratio, # type_mode 19 | ] 20 | 21 | trans_type = { 22 | type_size: _('SIZE'), 23 | type_dimensions: _('DIMENSIONS'), 24 | type_format: _('FORMAT'), 25 | type_aspect_ratio: _('ASPECT RATIO'), 26 | type_mode: _('MODE'), 27 | } 28 | 29 | trans_and = _('and') 30 | trans_or = _('or') 31 | 32 | err = 'err' 33 | 34 | w = 'w' 35 | h = 'h' 36 | width_height_operators = {w, h} 37 | 38 | trans_wh = { 39 | w: _('width'), 40 | h: _('height'), 41 | } 42 | 43 | lt = 'lt' 44 | lte = 'lte' 45 | gt = 'gt' 46 | gte = 'gte' 47 | ne = 'ne' 48 | eq = 'eq' 49 | 50 | comparison_operators = { 51 | lt: operator.lt, 52 | lte: operator.le, 53 | gt: operator.gt, 54 | gte: operator.ge, 55 | ne: operator.ne, 56 | eq: operator.eq, 57 | } 58 | valid_operators_strings = list(comparison_operators.keys()) 59 | 60 | human_opr = { 61 | lt: _('less than'), 62 | lte: _('less than or equal to'), 63 | gt: _('greater than'), 64 | gte: _('greater than or equal to'), 65 | ne: _('not equal to'), 66 | eq: _('equal to'), 67 | } 68 | 69 | allowable_web_image_extensions = ['jpeg', 'png', 'gif', 'bmp', 'webp'] 70 | 71 | 72 | errors = { 73 | 'empty_dict': 'The value of the rule "{name}", "{rule}", should be a ' 74 | 'non-empty dict.', 75 | } 76 | 77 | 78 | def nonsense_operators(): 79 | """ 80 | A list of sets which makes no sense to appear together in a single 81 | validation rule. For example, there is no sense to apply to an image, 82 | a validation rule for, i.e, ``'SIZE'``, that's gonna be ``less than 1000px 83 | AND less than or equal 1100px``. 84 | Or, ``greater than 500px and equal to 785px``! 85 | 86 | :return: list of 2-length sets 87 | """ 88 | return [ 89 | {lt, lte}, # "less than" and "less than or equal"? Nonsense 90 | {gt, gte}, # "greater than" and "greater than or equal"? Nonsense 91 | {lt, eq}, # "less than" and "equal"? Nonsense 92 | {gt, eq}, # "greater than" and "equal"? Nonsense 93 | {lte, eq}, # "less than or equal" and "equal"? Nonsense 94 | {gte, eq}, # "greater than" and "equal"? Nonsense 95 | {ne, eq}, # "equal" and "non equal"? Nonsense 96 | ] 97 | 98 | 99 | def nonsense_values_together(): 100 | """ 101 | This list represents nonsense pairs of value keys of a 102 | :class:`~vimage.core.base.VimageValue` instance. 103 | 104 | For example, a ``VimageValue`` instance may not have set both 105 | ``'DIMENSIONS'`` and ``'ASPECT_RATIO'`` validation strings. 106 | Every pair inside this list is mutual exclusive. 107 | 108 | :return: list of 2-length sets 109 | """ 110 | return [ 111 | {type_dimensions, type_aspect_ratio}, 112 | ] 113 | 114 | 115 | def docstring_parameter(*args, **kwargs): 116 | """ 117 | A decorator that overrides the docstring of a method. 118 | 119 | :param args: any args passed to the function 120 | :param kwargs: any kwargs passed to the function 121 | :return: function 122 | """ 123 | def decor(func): 124 | func.__doc__ = func.__doc__.format(*args, **kwargs) 125 | return func 126 | return decor 127 | -------------------------------------------------------------------------------- /vimage/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | 3 | 4 | class MissingConfigError(ImproperlyConfigured): 5 | """ 6 | Configuration dict is missing from settings module 7 | """ 8 | pass 9 | 10 | 11 | class InvalidConfigValueError(ImproperlyConfigured): 12 | """ 13 | Configuration dict is not a dictionary 14 | """ 15 | pass 16 | 17 | 18 | class EmptyConfigError(ImproperlyConfigured): 19 | """ 20 | Configuration dict is an empty dict 21 | """ 22 | pass 23 | 24 | 25 | class InvalidKeyError(ImproperlyConfigured): 26 | """ Configuration dict has invalid key """ 27 | pass 28 | 29 | 30 | class InvalidValueError(ImproperlyConfigured): 31 | """ Configuration dict has invalid value """ 32 | pass 33 | -------------------------------------------------------------------------------- /vimage/locale/el/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manikos/django-vimage/9011bba4c47eecbbae092971073ab7803777c472/vimage/locale/el/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /vimage/locale/el/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-04-05 12:58+0300\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: core/const.py:25 22 | msgid "SIZE" 23 | msgstr "ΜΕΓΕΘΟΣ" 24 | 25 | #: core/const.py:26 26 | msgid "DIMENSIONS" 27 | msgstr "ΔΙΑΣΤΑΣΕΙΣ" 28 | 29 | #: core/const.py:27 30 | msgid "FORMAT" 31 | msgstr "ΤΥΠΟΣ" 32 | 33 | #: core/const.py:28 34 | msgid "ASPECT RATIO" 35 | msgstr "ΑΝΑΛΟΓΙΑ ΠΛΑΤΟΥΣ ΥΨΟΥΣ" 36 | 37 | #: core/const.py:29 38 | msgid "MODE" 39 | msgstr "ΜΟΡΦΗ" 40 | 41 | #: core/const.py:32 42 | msgid "and" 43 | msgstr "και" 44 | 45 | #: core/const.py:33 46 | msgid "or" 47 | msgstr "ή" 48 | 49 | #: core/const.py:40 50 | msgid "width" 51 | msgstr "πλάτος" 52 | 53 | #: core/const.py:41 54 | msgid "height" 55 | msgstr "ύψος" 56 | 57 | #: core/const.py:62 58 | msgid "less than" 59 | msgstr "μικρότερο από" 60 | 61 | #: core/const.py:63 62 | msgid "less than or equal to" 63 | msgstr "μικρότερο από ή ίσο με" 64 | 65 | #: core/const.py:64 66 | msgid "greater than" 67 | msgstr "μεγαλύτερο από" 68 | 69 | #: core/const.py:65 70 | msgid "greater than or equal to" 71 | msgstr "μεγαλύτερο από ή ίσο με" 72 | 73 | #: core/const.py:66 74 | msgid "not equal to" 75 | msgstr "όχι ίσο με" 76 | 77 | #: core/const.py:67 78 | msgid "equal to" 79 | msgstr "ίσο με" 80 | 81 | #: core/validator_types.py:251 82 | #, python-brace-format 83 | msgid "" 84 | "[IMAGE {rule_name}] Validation error: {value} does not meet validation rule: {rule}." 86 | msgstr "" 87 | "[{rule_name} ΕΙΚΟΝΑΣ] Μη έγκυρη τιμή: {value} δεν συμφωνεί με τον κανόνα: {rule}." 89 | 90 | #: core/validator_types.py:405 91 | msgid "one of the following dimensions" 92 | msgstr "μια από τις ακόλουθες διαστάσεις" 93 | 94 | #: core/validator_types.py:562 95 | msgid "the following formats" 96 | msgstr "τους ακόλουθους τύπους" 97 | 98 | #: core/validator_types.py:576 99 | msgid "one of" 100 | msgstr "έναν από" 101 | 102 | #: core/validator_types.py:742 103 | msgid "width divided by height should be {}" 104 | msgstr "ο λόγος πλάτους προς ύψος θα πρέπει να είναι {}" 105 | --------------------------------------------------------------------------------