├── .coveragerc ├── .github └── workflows │ ├── apt-get-update.sh │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── LICENSE.Jcrop.txt ├── MANIFEST.in ├── README.md ├── README.rst ├── cropduster ├── __init__.py ├── exceptions.py ├── fields.py ├── files.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alt_text.py │ └── __init__.py ├── models.py ├── resizing.py ├── settings.py ├── standalone │ ├── __init__.py │ ├── metadata.py │ ├── models.py │ ├── static │ │ └── ckeditor │ │ │ ├── ckeditor-dev │ │ │ └── ckeditor │ │ │ └── plugins │ │ │ └── cropduster │ │ │ ├── dialogs │ │ │ └── cropduster.js │ │ │ ├── icons │ │ │ └── cropduster.png │ │ │ ├── lang │ │ │ └── en.js │ │ │ └── plugin.js │ └── views.py ├── static │ └── cropduster │ │ ├── css │ │ ├── cropduster.css │ │ ├── jcrop.gif │ │ ├── jquery.jcrop.css │ │ └── upload.css │ │ ├── img │ │ ├── arrows.png │ │ ├── blank.gif │ │ ├── cropduster_icon_upload_hover.png │ │ ├── cropduster_icon_upload_select.png │ │ └── progressbar.gif │ │ └── js │ │ ├── cropduster.js │ │ ├── jquery.class.js │ │ ├── jquery.form.js │ │ ├── jquery.jcrop.js │ │ ├── jquery.jcrop.min.js │ │ ├── json2.js │ │ ├── jsrender.js │ │ └── upload.js ├── templates │ └── cropduster │ │ ├── custom_field.html │ │ ├── inline.html │ │ └── upload.html ├── templatetags │ ├── __init__.py │ └── cropduster_tags.py ├── urls.py ├── utils │ ├── __init__.py │ ├── gifsicle.py │ ├── image.py │ ├── jsonutils.py │ ├── paths.py │ ├── sizes.py │ └── thumbs.py └── views │ ├── __init__.py │ ├── base.py │ ├── forms.py │ └── utils.py ├── docs ├── Makefile ├── changelog.rst ├── conf.py ├── customization.rst ├── how_it_works.rst ├── index.rst └── quickstart.rst ├── pytest.ini ├── setup.py ├── tests ├── __init__.py ├── admin.py ├── conftest.py ├── data │ ├── animated-duration.gif │ ├── animated.gif │ ├── best-fit-off-by-one-bug.png │ ├── cmyk.jpg │ ├── img.jpg │ ├── img.png │ ├── img2.jpg │ ├── size-order-bug.png │ └── transparent.png ├── helpers.py ├── models.py ├── settings.py ├── standalone │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ └── test_admin.py ├── templates │ ├── 404.html │ └── 500.html ├── test_admin.py ├── test_gifsicle.py ├── test_models.py ├── test_resizing.py ├── test_utils.py ├── test_views.py ├── urls.py └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | cropduster/*migrations/* 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/apt-get-update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eo pipefail 4 | 5 | aptget_update() 6 | { 7 | if [ ! -z $1 ]; then 8 | echo "" 9 | echo "Retrying apt-get update..." 10 | echo "" 11 | fi 12 | output=`sudo apt-get update 2>&1` 13 | echo "$output" 14 | if [[ $output == *[WE]:\ * ]]; then 15 | return 1 16 | fi 17 | } 18 | 19 | aptget_update || aptget_update retry || aptget_update retry 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | grappelli: ["0"] 11 | python-version: ["3.8"] 12 | django-version: ["3.2"] 13 | s3: ["0"] 14 | include: 15 | - grappelli: "0" 16 | name-suffix: "" 17 | - python-version: "3.7" 18 | django-version: "2.2" 19 | - grappelli: "1" 20 | python-version: "3.8" 21 | django-version: "2.2" 22 | name-suffix: " + grappelli" 23 | - python-version: "3.11" 24 | django-version: "4.2" 25 | - grappelli: "1" 26 | name-suffix: " + grappelli" 27 | python-version: "3.9" 28 | django-version: "3.2" 29 | - grappelli: "1" 30 | name-suffix: " + grappelli" 31 | python-version: "3.10" 32 | django-version: "4.0" 33 | - grappelli: "0" 34 | s3: "1" 35 | python-version: "3.8" 36 | django-version: "3.2" 37 | name-suffix: " + s3" 38 | 39 | runs-on: ubuntu-latest 40 | name: Django ${{ matrix.django-version }} (Python ${{ matrix.python-version }})${{ matrix.name-suffix }} 41 | 42 | env: 43 | PYTHON: ${{ matrix.python-version }} 44 | DJANGO: ${{ matrix.django-version }} 45 | GRAPPELLI: ${{ matrix.grappelli }} 46 | S3: ${{ matrix.s3 }} 47 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 48 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 49 | NODE_ENV: test 50 | 51 | steps: 52 | - uses: actions/checkout@v3 53 | 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v4 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Install system dependencies 60 | run: | 61 | sudo .github/workflows/apt-get-update.sh 62 | sudo apt-get install -y exempi gifsicle 63 | 64 | - name: Install tox 65 | run: | 66 | python3 -m pip install tox tox-gh-actions 67 | 68 | - name: Run tests 69 | run: | 70 | tox -- -v --selenosis-driver=chrome-headless || \ 71 | tox -- -v --selenosis-driver=chrome-headless || \ 72 | tox -- -v --selenosis-driver=chrome-headless 73 | 74 | - name: Upload junit xml 75 | if: always() 76 | uses: actions/upload-artifact@v2 77 | with: 78 | name: junit-reports 79 | path: reports/*.xml 80 | 81 | - name: Combine coverage 82 | run: tox -e coverage-report 83 | 84 | - name: Upload coverage 85 | uses: codecov/codecov-action@v3 86 | with: 87 | name: ${{ github.workflow }} 88 | files: .tox/coverage/coverage.xml 89 | env_vars: "DJANGO,GRAPPELLI,PYTHON,S3" 90 | 91 | report: 92 | if: always() 93 | needs: build 94 | runs-on: ubuntu-latest 95 | name: "Report Test Results" 96 | steps: 97 | - uses: actions/download-artifact@v2 98 | with: 99 | name: junit-reports 100 | 101 | - name: Publish Unit Test Results 102 | if: always() 103 | uses: mikepenz/action-junit-report@1a91e26932fb7ba410a31fab1f09266a96d29971 104 | with: 105 | report_paths: ./*.xml 106 | require_tests: true 107 | fail_on_failure: true 108 | check_name: Test Report 109 | github_token: ${{ secrets.GITHUB_TOKEN }} 110 | 111 | success: 112 | needs: [report] 113 | runs-on: ubuntu-latest 114 | name: Test Successful 115 | steps: 116 | - name: Success 117 | run: echo Test Successful 118 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .DS_Store 3 | build 4 | *.egg-info 5 | .tox 6 | ghostdriver.log 7 | test/media 8 | dist/ 9 | .coverage 10 | /reports/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | **4.11.13 (Aug 2, 2018)** 5 | 6 | * Fix Django 1.11 that prevented updating images in standalone mode 7 | * Fix bug that threw exempi exceptions when uploaded images had iPhone face-recognition region metadata 8 | 9 | **4.11.12 (Jul 3, 2018)** 10 | 11 | * Fix Django 1.11 bug where newly uploaded images weren't named correctly. 12 | 13 | **4.11.11 (Jun 6, 2018)** 14 | 15 | * Support Django 2.0 and Django 2.1 alpha 16 | 17 | **4.11.10 (Jun 6, 2018)** 18 | 19 | * Fix Django 1.11 bug that prevented save of existing images 20 | 21 | **4.11.9 (Mar 28, 2018)** 22 | 23 | * Add ``skip_existing`` kwarg to ``generate_thumbs()`` method 24 | 25 | **4.11.0 (Mar 12, 2017)** 26 | 27 | * Add support for Django 1.10, drop support for Django < 1.8 28 | 29 | **4.10.0 (July 26, 2015)** 30 | 31 | * New: Add Image.alt_text field (requires a migration), which also gets returned now in the {% get_crop %} templatetag. 32 | * Removed: ``exact_size`` argument for ``get_crop`` templatetag. Looking up exact 33 | sizes in the database and including the caption/attribution/alt_text is now the 34 | default behavior. 35 | 36 | **4.9.0 (May 13, 2016)** 37 | 38 | * Fixed: upload and crop views now require admin login 39 | 40 | **4.8.49 (Apr 14, 2016)** 41 | 42 | * Fix bugs with ``regenerate_thumbs()`` when ``permissive=True`` 43 | 44 | **4.8.41 (Dec 16, 2015)** 45 | 46 | * New: Django 1.9 support 47 | 48 | **4.8.39 (Oct 28, 2015)** 49 | 50 | * Fixed: bug in ``best_fit`` calculation where scaling could cause the image dimensions to drop below mins. 51 | 52 | **4.8.38 (Oct 22, 2015)** 53 | 54 | * Fixed: Bug where ``for_concrete_model`` might not be set correctly. 55 | 56 | **4.8.37 (Sep 28, 2015)** 57 | 58 | * New: Add ability to retain xmp metadata (if ``CROPDUSTER_RETAIN_METADATA = True``) 59 | 60 | **4.8.36 (Sep 17, 2015)** 61 | 62 | * Improved: optimized cropduster inline formset with ``prefetch_related`` on ``thumbs`` 63 | 64 | **4.8.35 (Sep 3, 2015)** 65 | 66 | * Fixed: Initial migrations in Django 1.8. 67 | 68 | **4.8.34 (Aug 30, 2015)** 69 | 70 | * Fixed: The python-xmp-toolkit package is now optional. 71 | 72 | **4.8.32 (Jul 27, 2015)** 73 | 74 | * Improved: Drag resizing of non-corner handlers in jCrop scales in a more sensible way. 75 | 76 | **4.8.31 (Jul 26, 2015)** 77 | 78 | * Fixed: Center initial crop when min/max aspect ratio is specified 79 | 80 | **4.8.30 (Jul 22, 2015)** 81 | 82 | * Fixed: A bug in updates when CropDusterField is defined on a parent model 83 | 84 | **4.8.28 (Jul 16, 2015)** 85 | 86 | * Fixed: CropDusterField kwargs ``min_w``, ``min_h``, ``max_w``, and ``max_h`` now work as expected. 87 | 88 | **4.8.26 (Jul 12, 2015)** 89 | 90 | * Fixed: AttributeError in Django 1.6+ when using custom cropduster formfield 91 | * Fixed: Updated django-generic-plus to fix an issue with multiple CropDusterFields spanning model inheritance. 92 | 93 | **4.8.25 (Jul 11, 2015)** 94 | 95 | * Fixed: Orphaned thumbs were being created when cropping images with multiple sizes (issue #41) 96 | 97 | **4.8.23 (Jun 15, 2015)** 98 | 99 | * Fixed: Off-by-one rounding bug in Size.fit_to_crop() 100 | 101 | **4.8.22 (Jun 12, 2015)** 102 | 103 | * Improved: Show help text about minimum image on upload dialog, when applicable. 104 | 105 | **4.8.19 (Jun 9, 2015)** 106 | 107 | * Improved: Animated GIFs are now processed by gifsicle if available 108 | * New: Added actual documentation 109 | * New: Add setting CROPDUSTER_JPEG_QUALITY; can be numeric or a callable 110 | 111 | **4.8.18 (Jun 5, 2015)** 112 | 113 | * Fixed: Non-South migrations in Django 1.7 and 1.8 were broken. 114 | * Improved: Appearance of the cropduster widget in the Django admin without Grappelli. 115 | 116 | **4.8.17 (May 31, 2015)** 117 | 118 | * New: Grappelli is no longer required to use django-cropduster. 119 | * Fixed: Python 3 bug in ``cropduster.models.Thumb.to_dict()``. 120 | 121 | **4.8.16 (May 29, 2015)** 122 | 123 | * New: Django 1.8 compatibility. 124 | 125 | **4.8.15 (May 5, 2015)** 126 | 127 | * Fixed: bug where blank ``Image.path`` prevents image upload. 128 | 129 | **4.8.14 (Apr 28, 2015)** 130 | 131 | * Improved: Image dimensions are no longer recalculated on every save. 132 | 133 | **4.8.13 (Apr 21, 2015)** 134 | 135 | * Improved: Added cachebusting to ``get_crop`` templatetag. 136 | 137 | **4.8.10 (Apr 12, 2015)** 138 | 139 | * New: Add ``required`` keyword argument to ``Size``, allowing for crops which are only generated if the image and crop dimensions are large enough. 140 | 141 | **4.8.8 (Apr 10, 2015)** 142 | 143 | * Improved: Use bicubic downsampling when generating crops with Pillow version >= 2.7.0. 144 | * Improved: Retain ICC color profile when saving image, if Pillow has JPEG ICC support. 145 | 146 | **4.8.7 (Mar 18, 2015)** 147 | 148 | * Fixed: ``field_identifier`` now defaults to empty string, not ``None``. 149 | * Fixed: Bug that caused small JPEG crops to be saved at poor quality. 150 | 151 | **4.8.4 (Mar 5, 2015)** 152 | 153 | * New: Give cropduster a logo. 154 | 155 | **4.8.3 (Feb 23, 2015)** 156 | 157 | * New: Make default JPEG quality vary based on the size of the image; add `get_jpeg_quality` setting that allows for overriding the default JPEG quality. 158 | 159 | **4.8.0 (Feb 12, 2015)** 160 | 161 | * New: Django 1.7 compatibility 162 | * New: Add ``field_identifier`` keyword argument to ``CropDusterField``, which allows for multiple ``CropDusterField`` fields on a single model. 163 | * New: Add unit tests, including Selenium tests. 164 | 165 | **4.7.6 (Jan 21, 2015)** 166 | 167 | * Fix: Bug in ``CropDusterImageFieldFile.generate_thumbs`` method 168 | 169 | **4.7.5 (Jan 21, 2015)** 170 | 171 | * New: Add ``CropDusterImageFieldFile.generate_thumbs`` method, which generates and updates crops for a ``CropDusterField``. 172 | 173 | **4.7.4 (Dec 17, 2014)** 174 | 175 | * Improved: Height of CKEditor dialog for smaller monitors. 176 | * Improved: Add convenience ``@property`` helpers: ``Thumb.image_file``, ``Thumb.url``, ``Thumb.path``, and ``Image.url``. 177 | * Improved: Use filters passed to ``limit_choices_to`` keyword argument in ``ReverseForeignRelation``. 178 | 179 | **4.7.3 (Nov 25, 2014)** 180 | 181 | * Fixed: Regression from 4.7.2 where ``get_crop`` templatetag did not always return an image. 182 | 183 | **4.7.1 (Oct 16, 2014)** 184 | 185 | * Improved: ``Image.caption`` field no longer has a maximum length. 186 | 187 | **4.6.4 (Jul 10, 2014)** 188 | 189 | * Fixed: Querysets of the form ``Image.objects.filter(thumbs__x=...)``. 190 | * Improved: Disable "Upload" button before a file has been chosen. 191 | * Fixed: Error in CKEditor widget triggered by user clicking the "OK" button without uploading an image. 192 | 193 | **4.6.3 (Jul 9, 2014)** 194 | 195 | * Fixed: Python 3 regression that raised ``ValueError`` when the form received an empty string for the ``thumbs`` field. 196 | * Improved: Style and functionality of the delete checkbox. 197 | 198 | **4.6.2 (Jul 9, 2014)** 199 | 200 | * Fixed: Deleting a cropduster image did not clear the file field on the generic-related instance, which caused cropduster to subsequently render file widgets in legacy mode. 201 | 202 | **4.6.1 (Jul 8, 2014)** 203 | 204 | * Fixed: Bug that prevented CKEditor plugin from downloading external images already existing in WYSIWYG. 205 | 206 | **4.6.0 (Jul 8, 2014)** 207 | 208 | * Python 3 compatibility 209 | * Django 1.6 compatibility 210 | * Removed: Dependency on ``jsonutils``. 211 | * Improved: Support ``python-xmp-toolkit`` 2.0.0+. 212 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is published under the BSD 2-Clause License as listed below. 2 | http://www.opensource.org/licenses/bsd-license.php 3 | 4 | Copyright (c) 2015, Atlantic Media 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /LICENSE.Jcrop.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Tapmodo Interactive LLC, 2 | http://github.com/tapmodo/Jcrop 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include LICENSE.Jcrop.txt 3 | include README.md 4 | include README.rst 5 | recursive-include cropduster/standalone/static * 6 | recursive-include cropduster/static * 7 | recursive-include cropduster/templates * 8 | recursive-include cropduster/tests/data * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-cropduster 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/theatlantic/django-cropduster.svg?branch=master)](https://travis-ci.org/theatlantic/django-cropduster) 5 | 6 | Cropduster logo 7 | 8 | **django-cropduster** is a project that makes a form field available that 9 | uses the [Jcrop jQuery plugin](https://github.com/tapmodo/Jcrop). It is a drop-in 10 | replacement for django's `ImageField` and allows users to generate multiple crops 11 | from images, using predefined sizes and aspect ratios. **django-cropduster** 12 | was created by developers at [The Atlantic](http://www.theatlantic.com/). It 13 | is compatible with python 2.7 and 3.4, and Django versions 1.4 - 1.8. 14 | 15 | * [Installation](#installation) 16 | * [Configuration](#configuration) 17 | * [Documentation & Examples](#documentation--examples) 18 | * [License](#license) 19 | 20 | Installation 21 | ------------ 22 | 23 | The recommended way to install django-cropduster is from [PyPI](https://pypi.python.org/pypi/django-cropduster): 24 | 25 | pip install django-cropduster 26 | 27 | Alternatively, one can install a development copy of django-cropduster from 28 | source: 29 | 30 | pip install -e git+git://github.com/theatlantic/django-cropduster.git#egg=django-cropduster 31 | 32 | If the source is already checked out, use setuptools: 33 | 34 | python setup.py develop 35 | 36 | Configuration 37 | ------------- 38 | 39 | To enable django-cropduster, `"cropduster"` must be added to `INSTALLED_APPS` 40 | in settings.py and you must include `cropduster.urls` in your django 41 | urlpatterns. 42 | 43 | ```python 44 | # settings.py 45 | 46 | INSTALLED_APPS = ( 47 | # ... 48 | 'cropduster', 49 | ) 50 | 51 | # urls.py 52 | 53 | urlpatterns = patterns('', 54 | # ... 55 | url(r'^cropduster/', include('cropduster.urls')), 56 | ) 57 | ``` 58 | 59 | Documentation & Examples 60 | ------------------------ 61 | 62 | class Size(name, [label=None, w=None, h=None, auto=None, 63 | min_w=None, min_h=None, max_w=None, max_h=None, required=True]) 64 | 65 | Use `Size` to define your crops. The `auto` parameter can be set to a list of 66 | other `Size` objects that will be automatically generated based on the 67 | user-selected crop of the parent `Size`. 68 | 69 | `CropDusterField` accepts the same arguments as Django's built-in `ImageField` 70 | but with an additional `sizes` keyword argument, which accepts a list of 71 | `Size` objects. 72 | 73 | An example models.py: 74 | 75 | ```python 76 | from cropduster.models import CropDusterField, Size 77 | 78 | class ExampleModel(models.Model): 79 | MODEL_SIZES = [ 80 | # array of Size objects for initial crop 81 | Size("large", w=210, auto=[ 82 | # array of Size objects auto cropped based on container Size 83 | Size('larger', w=768), 84 | Size('medium', w=85, h=113), 85 | # more sub Size objects ... 86 | ]), 87 | # more initial crop Size objects ... 88 | ] 89 | 90 | image = CropDusterField(upload_to="your/path/goes/here", sizes=MODEL_SIZES) 91 | ``` 92 | 93 | To get a dictionary containing information about an image within a template, 94 | use the `get_crop` templatetag: 95 | 96 | ```django 97 | {% load cropduster_tags %} 98 | 99 | {% get_crop obj.image 'large' exact_size=1 as img %} 100 | 101 | {% if img %} 102 |
103 | {{ img.caption }} 105 | {% if img.attribution %} 106 |
107 | {{ img.caption }} (credit: {{ img.attribution }}) 108 |
109 | {% endif %} 110 |
111 | {% endif %} 112 | ``` 113 | 114 | License 115 | ------- 116 | The django code is licensed under the 117 | [Simplified BSD License](http://opensource.org/licenses/BSD-2-Clause). View 118 | the `LICENSE` file under the root directory for complete license and copyright 119 | information. 120 | 121 | The Jcrop jQuery library included is used under the 122 | [MIT License](https://github.com/tapmodo/Jcrop/blob/master/MIT-LICENSE.txt). 123 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-cropduster 2 | ################# 3 | 4 | **django-cropduster** is a project that makes a form field available 5 | that uses the `Jcrop jQuery 6 | plugin `_. It is a drop-in 7 | replacement for django's ``ImageField`` and allows users to generate 8 | multiple crops from images, using predefined sizes and aspect ratios. 9 | **django-cropduster** was created by developers at `The 10 | Atlantic `_. It is compatible with python 11 | 2.7 and 3.4, and Django versions 1.4 - 1.8. 12 | 13 | Installation 14 | ============ 15 | 16 | The recommended way to install django-cropduster is from 17 | `PyPI `_:: 18 | 19 | pip install django-cropduster 20 | 21 | Alternatively, one can install a development copy of django-cropduster 22 | from source:: 23 | 24 | pip install -e git+git://github.com/theatlantic/django-cropduster.git#egg=django-cropduster 25 | 26 | If the source is already checked out, use setuptools:: 27 | 28 | python setup.py develop 29 | 30 | Configuration 31 | ============= 32 | 33 | To enable django-cropduster, ``"cropduster"`` must be added to 34 | ``INSTALLED_APPS`` in settings.py and you must include 35 | ``cropduster.urls`` in your django urlpatterns. 36 | 37 | :: 38 | 39 | # settings.py 40 | 41 | INSTALLED_APPS = ( 42 | # ... 43 | 'cropduster', 44 | ) 45 | 46 | # urls.py 47 | 48 | urlpatterns = patterns('', 49 | # ... 50 | url(r'^cropduster/', include('cropduster.urls')), 51 | ) 52 | 53 | License 54 | ======= 55 | 56 | The django code is licensed under the `Simplified BSD 57 | License `_. View the 58 | ``LICENSE`` file under the root directory for complete license and 59 | copyright information. 60 | 61 | The Jcrop jQuery library included is used under the `MIT 62 | License `_. 63 | -------------------------------------------------------------------------------- /cropduster/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.14.4' 2 | -------------------------------------------------------------------------------- /cropduster/exceptions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import logging 4 | import copy 5 | import errno 6 | 7 | from django.urls import get_urlconf, get_resolver 8 | from django.http import HttpResponse 9 | from django.utils.safestring import mark_safe 10 | 11 | from django.utils.encoding import force_str 12 | 13 | 14 | logger = logging.getLogger('cropduster') 15 | 16 | 17 | SentryHandler = raven_client = None 18 | 19 | 20 | try: 21 | from sentry.client.handlers import SentryHandler 22 | except ImportError: 23 | try: 24 | from raven.contrib.django.models import get_client 25 | except ImportError: 26 | def get_client(*args, **kwargs): 27 | return None 28 | 29 | 30 | if SentryHandler: 31 | logger.addHandler(SentryHandler()) 32 | 33 | 34 | class FauxTb(object): 35 | 36 | def __init__(self, tb_frame, tb_lineno, tb_next): 37 | self.tb_frame = tb_frame 38 | self.tb_lineno = tb_lineno 39 | self.tb_next = tb_next 40 | 41 | 42 | def current_stack(skip=0): 43 | try: 44 | 1 / 0 45 | except ZeroDivisionError: 46 | f = sys.exc_info()[2].tb_frame 47 | for i in range(skip + 2): 48 | f = f.f_back 49 | lst = [] 50 | while f is not None: 51 | lst.append((f, f.f_lineno)) 52 | f = f.f_back 53 | return lst 54 | 55 | 56 | def extend_traceback(tb, stack): 57 | """Extend traceback with stack info.""" 58 | head = tb 59 | for tb_frame, tb_lineno in stack: 60 | head = FauxTb(tb_frame, tb_lineno, head) 61 | return head 62 | 63 | 64 | def full_exc_info(): 65 | """Like sys.exc_info, but includes the full traceback.""" 66 | t, v, tb = sys.exc_info() 67 | full_tb = extend_traceback(tb, current_stack(1)) 68 | return t, v, full_tb 69 | 70 | 71 | def format_error(error): 72 | from generic_plus.utils import get_relative_media_url 73 | 74 | if isinstance(error, str): 75 | return error 76 | elif isinstance(error, IOError): 77 | if error.errno == errno.ENOENT: # No such file or directory 78 | file_name = get_relative_media_url(error.filename) 79 | return "Could not find file %s" % file_name 80 | 81 | return "[%(type)s] %(msg)s" % { 82 | 'type': error.__class__.__name__, 83 | 'msg': error, 84 | } 85 | 86 | 87 | def log_error(request, view, action, errors, exc_info=None): 88 | # We only log the first error, send the rest as data; it's simpler this way 89 | error_msg = "Error %s: %s" % (action, format_error(errors[0])) 90 | 91 | log_kwargs = {} 92 | 93 | if not exc_info: 94 | try: 95 | exc_info = full_exc_info() 96 | except: 97 | exc_info = None 98 | if exc_info and not isinstance(exc_info, tuple) or not len(exc_info) or not exc_info[0]: 99 | exc_info = None 100 | 101 | if exc_info: 102 | log_kwargs["exc_info"] = exc_info 103 | 104 | extra_data = { 105 | 'errors': errors, 106 | 'process_id': os.getpid() 107 | } 108 | 109 | try: 110 | import psutil, math, time, thread 111 | except ImportError: 112 | pass 113 | else: 114 | p = psutil.Process(os.getpid()) 115 | proc_timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(p.create_time)) 116 | try: 117 | create_usec = str(p.create_time - math.floor(p.create_time))[1:5] 118 | except: 119 | create_usec = '' 120 | proc_timestamp += create_usec 121 | extra_data['process_create_date'] = proc_timestamp 122 | extra_data['thread_id'] = thread.get_ident() 123 | 124 | if isinstance(errors[0], CropDusterUrlException): 125 | urlconf = get_urlconf() 126 | resolver = get_resolver(urlconf) 127 | extra_data['resolver_data'] = { 128 | "regex": resolver.regex, 129 | "urlconf_name": resolver.urlconf_name, 130 | "default_kwargs": resolver.default_kwargs, 131 | "namespace": resolver.namespace, 132 | "urlconf_module": resolver.urlconf_module 133 | } 134 | 135 | resolver_reverse_dict = dict( 136 | [(force_str(k), resolver.reverse_dict[k]) for k in resolver.reverse_dict]) 137 | resolver_namespace_dict = dict( 138 | [(force_str(k), resolver.namespace_dict[k]) for k in resolver.namespace_dict]) 139 | 140 | extra_data.update({ 141 | 'resolver_data': { 142 | "regex": resolver.regex, 143 | "urlconf_name": resolver.urlconf_name, 144 | "default_kwargs": resolver.default_kwargs, 145 | "namespace": resolver.namespace, 146 | "urlconf_module": resolver.urlconf_module 147 | }, 148 | 'resolver_reverse_dict': resolver_reverse_dict, 149 | 'resolver_namespace_dict': resolver_namespace_dict, 150 | 'resolver_app_dict': resolver.app_dict, 151 | 'resolver_url_patterns': resolver.url_patterns, 152 | 'urlconf': urlconf, 153 | 'view': 'cropduster.views.%s' % view, 154 | }) 155 | 156 | raven_kwargs = {'request': request, 'extra': extra_data, 'data': {'message': error_msg}} 157 | 158 | raven_client = get_client() 159 | if raven_client: 160 | if exc_info: 161 | return raven_client.get_ident( 162 | raven_client.captureException(exc_info=exc_info, **raven_kwargs)) 163 | else: 164 | return raven_client.get_ident( 165 | raven_client.captureMessage(error_msg, **raven_kwargs)) 166 | else: 167 | extra_data.update({ 168 | 'request': request, 169 | 'url': request.path_info, 170 | }) 171 | logger.error(error_msg, extra=extra_data, **log_kwargs) 172 | return None 173 | 174 | 175 | def json_error(request, view, action, errors=None, forms=None, formsets=None, log=False, exc_info=None): 176 | from .utils import json 177 | 178 | if forms: 179 | formset_errors = [[copy.deepcopy(f.errors) for f in forms]] 180 | elif formsets: 181 | formset_errors = [copy.deepcopy(f.errors) for f in formsets] 182 | else: 183 | formset_errors = [] 184 | 185 | if not errors and not formset_errors: 186 | return HttpResponse(json.dumps({'error': 'An unknown error occurred'}), 187 | content_type='application/json') 188 | 189 | error_str = '' 190 | for forms in formset_errors: 191 | for form_errors in forms: 192 | for k in sorted(form_errors.keys()): 193 | v = form_errors.pop(k) 194 | k = mark_safe('%(k)s' % {'k': k}) 195 | form_errors[k] = v 196 | error_str += force_str(form_errors) 197 | errors = errors or [error_str] 198 | 199 | if log: 200 | log_error(request, view, action, errors, exc_info=exc_info) 201 | 202 | if len(errors) == 1: 203 | error_msg = "Error %s: %s" % (action, format_error(errors[0])) 204 | else: 205 | error_msg = "Errors %s: " % action 206 | error_msg += "
    " 207 | for error in errors: 208 | error_msg += "
  •    • %s
  • " % format_error(error) 209 | error_msg += "
" 210 | return HttpResponse(json.dumps({'error': error_msg}), content_type='application/json') 211 | 212 | 213 | class CropDusterException(Exception): 214 | pass 215 | 216 | 217 | class CropDusterUrlException(CropDusterException): 218 | pass 219 | 220 | 221 | class CropDusterViewException(CropDusterException): 222 | pass 223 | 224 | 225 | class CropDusterModelException(CropDusterException): 226 | pass 227 | 228 | 229 | class CropDusterImageException(CropDusterException): 230 | pass 231 | 232 | 233 | class CropDusterFileException(CropDusterException): 234 | pass 235 | 236 | 237 | class CropDusterResizeException(CropDusterException): 238 | pass 239 | -------------------------------------------------------------------------------- /cropduster/files.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import os 4 | import re 5 | import hashlib 6 | 7 | from django.core.files.images import get_image_dimensions 8 | from django.core.files.uploadedfile import SimpleUploadedFile 9 | from django.core.files.storage import default_storage 10 | from django.conf import settings 11 | from django.db.models.fields.files import FieldFile, FileField 12 | from django.utils.functional import cached_property 13 | from urllib.parse import urlparse, unquote_plus 14 | from urllib.request import urlopen 15 | 16 | from generic_plus.utils import get_relative_media_url, get_media_path 17 | 18 | 19 | class VirtualFieldFile(FieldFile): 20 | 21 | def __init__(self, name, storage=None, upload_to=None): 22 | super(FieldFile, self).__init__(None, name) 23 | self.instance = None 24 | self.field = FileField(name='file', upload_to=upload_to, storage=storage) 25 | self.storage = self.field.storage 26 | self._committed = True 27 | 28 | def get_directory_name(self): 29 | return self.field.get_directory_name() 30 | 31 | def get_filename(self, filename): 32 | return self.field.get_filename(filename) 33 | 34 | def generate_filename(self, filename): 35 | return self.field.generate_filename(None, filename) 36 | 37 | def save(self, *args, **kwargs): 38 | raise NotImplementedError 39 | 40 | def delete(self, *args, **kwargs): 41 | raise NotImplementedError 42 | 43 | @cached_property 44 | def dimensions(self): 45 | try: 46 | close = self.closed 47 | self.open() 48 | return get_image_dimensions(self, close=close) 49 | except: 50 | return (0, 0) 51 | 52 | @cached_property 53 | def width(self): 54 | w, h = self.dimensions 55 | return w 56 | 57 | @cached_property 58 | def height(self): 59 | w, h = self.dimensions 60 | return h 61 | 62 | 63 | class ImageFile(VirtualFieldFile): 64 | 65 | _path = None 66 | 67 | preview_image = None 68 | metadata = None 69 | 70 | def __init__(self, path, upload_to=None, preview_w=None, preview_h=None): 71 | self.upload_to = upload_to 72 | self.preview_width = preview_w 73 | self.preview_height = preview_h 74 | self.metadata = {} 75 | 76 | if not path: 77 | self.name = None 78 | return 79 | 80 | if '%' in path: 81 | path = unquote_plus(path) 82 | 83 | if path.startswith(settings.MEDIA_URL): 84 | # Strips leading MEDIA_URL, if starts with 85 | self._path = get_relative_media_url(path, clean_slashes=False) 86 | elif re.search(r'^(?:http(?:s)?:)?//', path): 87 | # url on other server? download it. 88 | self._path = self.download_image_url(path) 89 | else: 90 | if default_storage.exists(path): 91 | self._path = path 92 | 93 | if not self._path: 94 | self.name = None 95 | return 96 | 97 | super(ImageFile, self).__init__(self._path) 98 | 99 | if self: 100 | self.preview_image = self.get_for_size('preview') 101 | 102 | def download_image_url(self, url): 103 | from cropduster.models import StandaloneImage 104 | from cropduster.views.forms import clean_upload_data 105 | 106 | image_contents = urlopen(url).read() 107 | md5_hash = hashlib.md5() 108 | md5_hash.update(image_contents) 109 | try: 110 | standalone_image = StandaloneImage.objects.get(md5=md5_hash.hexdigest()) 111 | except StandaloneImage.DoesNotExist: 112 | pass 113 | else: 114 | return get_relative_media_url(standalone_image.image.name) 115 | 116 | parse_result = urlparse(url) 117 | 118 | fake_upload = SimpleUploadedFile(os.path.basename(parse_result.path), image_contents) 119 | file_data = clean_upload_data({ 120 | 'image': fake_upload, 121 | 'upload_to': self.upload_to, 122 | }) 123 | return get_relative_media_url(file_data['image'].name) 124 | 125 | def __nonzero__(self): 126 | """When evaluated as boolean, base on whether self._path is not None""" 127 | if not self._path: 128 | return False 129 | return super(ImageFile, self).__nonzero__() 130 | 131 | def get_for_size(self, size_slug='original'): 132 | from cropduster.models import Image 133 | 134 | image = Image.get_file_for_size(self, size_slug) 135 | if size_slug == 'preview': 136 | if not default_storage.exists(image.name): 137 | Image.save_preview_file(self, preview_w=self.preview_width, preview_h=self.preview_height) 138 | return image 139 | -------------------------------------------------------------------------------- /cropduster/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from django.forms.models import ModelChoiceIterator 4 | from django.forms.models import ChoiceField, ModelMultipleChoiceField 5 | from django.forms.utils import flatatt 6 | from django.utils.encoding import force_str 7 | from django.utils.html import escape, conditional_escape 8 | 9 | from generic_plus.forms import BaseGenericFileInlineFormSet, GenericForeignFileWidget 10 | 11 | from .utils import json 12 | from cropduster.settings import ( 13 | CROPDUSTER_PREVIEW_WIDTH as PREVIEW_WIDTH, 14 | CROPDUSTER_PREVIEW_HEIGHT as PREVIEW_HEIGHT) 15 | 16 | __all__ = ('CropDusterWidget', 'CropDusterThumbFormField', 'CropDusterInlineFormSet') 17 | 18 | 19 | class CropDusterWidget(GenericForeignFileWidget): 20 | 21 | sizes = None 22 | 23 | template = "cropduster/custom_field.html" 24 | 25 | class Media: 26 | css = {'all': ('cropduster/css/cropduster.css',)} 27 | js = ( 28 | 'admin/js/jquery.init.js', 29 | 'cropduster/js/jsrender.js', 30 | 'cropduster/js/cropduster.js', 31 | ) 32 | 33 | def get_context_data(self, name, value, attrs=None, bound_field=None): 34 | ctx = super(CropDusterWidget, self).get_context_data(name, value, attrs, bound_field) 35 | sizes = self.sizes 36 | related_object = ctx['instance'] 37 | preview_url = '' 38 | preview_w = PREVIEW_WIDTH 39 | preview_h = PREVIEW_HEIGHT 40 | if related_object: 41 | preview_url = related_object.get_image_url(size_name='_preview') 42 | orig_width, orig_height = related_object.width, related_object.height 43 | if (orig_width and orig_height): 44 | resize_ratio = min(PREVIEW_WIDTH / float(orig_width), PREVIEW_HEIGHT / float(orig_height)) 45 | if resize_ratio < 1: 46 | preview_w = int(round(orig_width * resize_ratio)) 47 | preview_h = int(round(orig_height * resize_ratio)) 48 | 49 | if callable(sizes): 50 | instance = getattr(getattr(bound_field, 'form', None), 'instance', None) 51 | try: 52 | sizes_callable = sizes.__func__ 53 | except AttributeError: 54 | sizes_callable = sizes 55 | sizes = sizes_callable(instance, related=related_object) 56 | sizes = [s for s in sizes if not getattr(s, 'is_alias', False)] 57 | 58 | ctx.update({ 59 | 'sizes': json.dumps(sizes), 60 | 'preview_url': preview_url, 61 | 'preview_w': preview_w, 62 | 'preview_h': preview_h, 63 | }) 64 | return ctx 65 | 66 | 67 | class ThumbChoiceIterator(ModelChoiceIterator): 68 | 69 | def __iter__(self): 70 | if self.field.empty_label is not None: 71 | yield ("", self.field.empty_label) 72 | if getattr(self.field, 'cache_choices', None): 73 | if self.field.choice_cache is None: 74 | self.field.choice_cache = [ 75 | self.choice(obj) for obj in self.queryset 76 | ] 77 | for choice in self.field.choice_cache: 78 | yield choice 79 | else: 80 | for obj in self.queryset: 81 | yield self.choice(obj) 82 | 83 | def choice(self, obj): 84 | return (obj.pk, self.field.label_from_instance(obj)) 85 | 86 | 87 | class CropDusterThumbWidget(forms.SelectMultiple): 88 | 89 | def __init__(self, *args, **kwargs): 90 | from cropduster.models import Thumb 91 | 92 | super(CropDusterThumbWidget, self).__init__(*args, **kwargs) 93 | self.model = Thumb 94 | 95 | def get_option_attrs(self, value): 96 | if isinstance(value, self.model): 97 | thumb = value 98 | else: 99 | try: 100 | thumb = self.model.objects.get(pk=value) 101 | except (TypeError, self.model.DoesNotExist): 102 | return {} 103 | 104 | if thumb.image_id: 105 | thumb_url = thumb.image.get_image_url(size_name=thumb.name) 106 | else: 107 | thumb_url = None 108 | 109 | return { 110 | 'data-width': thumb.width, 111 | 'data-height': thumb.height, 112 | 'data-url': thumb_url, 113 | 'data-tmp-file': json.dumps(not(thumb.image_id)), 114 | } 115 | 116 | def create_option(self, *args, **kwargs): 117 | option = super(CropDusterThumbWidget, self).create_option(*args, **kwargs) 118 | option['attrs'].update(self.get_option_attrs(option['value'])) 119 | option['selected'] = True 120 | if isinstance(option['value'], self.model): 121 | option['value'] = option['value'].pk 122 | return option 123 | 124 | def render_option(self, selected_choices, option_value, option_label): 125 | attrs = self.get_option_attrs(option_value) 126 | if isinstance(option_value, self.model): 127 | option_value = option_value.pk 128 | option_value = force_str(option_value) 129 | if option_value in selected_choices: 130 | selected_html = ' selected="selected"' 131 | else: 132 | selected_html = '' 133 | return ( 134 | '') % { 135 | 'value': escape(option_value), 136 | 'selected': selected_html, 137 | 'attrs': flatatt(attrs), 138 | 'label': conditional_escape(force_str(option_label)), 139 | } 140 | 141 | 142 | class CropDusterThumbFormField(ModelMultipleChoiceField): 143 | 144 | widget = CropDusterThumbWidget 145 | 146 | def clean(self, value): 147 | """ 148 | Override default validation so that it doesn't throw a ValidationError 149 | if a given value is not in the original queryset. 150 | """ 151 | try: 152 | value = super(CropDusterThumbFormField, self).clean(value) 153 | except ValidationError as e: 154 | if self.error_messages['required'] in e.messages: 155 | raise 156 | elif self.error_messages['list'] in e.messages: 157 | raise 158 | return value 159 | 160 | def _get_choices(self): 161 | if hasattr(self, '_choices'): 162 | return self._choices 163 | return ThumbChoiceIterator(self) 164 | 165 | choices = property(_get_choices, ChoiceField._set_choices) 166 | 167 | 168 | def get_cropduster_field_on_model(model, field_identifier): 169 | from cropduster.fields import CropDusterField 170 | 171 | opts = model._meta 172 | m2m_fields = [f for f in opts.get_fields() if f.many_to_many and not f.auto_created] 173 | if hasattr(opts, 'private_fields'): 174 | # Django 1.10+ 175 | private_fields = opts.private_fields 176 | else: 177 | # Django < 1.10 178 | private_fields = opts.virtual_fields 179 | m2m_related_fields = set(m2m_fields + private_fields) 180 | 181 | field_match = lambda f: (isinstance(f, CropDusterField) 182 | and f.field_identifier == field_identifier) 183 | 184 | try: 185 | return [f for f in m2m_related_fields if field_match(f)][0] 186 | except IndexError: 187 | return None 188 | 189 | 190 | class CropDusterInlineFormSet(BaseGenericFileInlineFormSet): 191 | 192 | fields = ('image', 'thumbs', 'attribution', 'attribution_link', 193 | 'caption', 'alt_text', 'field_identifier') 194 | 195 | def __init__(self, *args, **kwargs): 196 | super(CropDusterInlineFormSet, self).__init__(*args, **kwargs) 197 | if self.instance and not self.data: 198 | cropduster_field = get_cropduster_field_on_model(self.instance.__class__, self.field_identifier) 199 | if cropduster_field: 200 | # An order_by() is required to prevent the queryset result cache 201 | # from being removed 202 | self.queryset = self.queryset.order_by('pk') 203 | field_file = getattr(self.instance, cropduster_field.name) 204 | self.queryset._result_cache = list(filter(None, [field_file.related_object])) 205 | 206 | def clean(self): 207 | if any(self.errors) or not self.require_alt_text: 208 | # Don't bother validating the formset unless each form is valid 209 | # and the `require_alt_text` setting is on 210 | return 211 | 212 | for form in self.forms: 213 | image = form.cleaned_data.get("image") 214 | alt_text = form.cleaned_data.get("alt_text") 215 | 216 | if image and not alt_text: 217 | form.add_error( 218 | "alt_text", "Alt text describing the image is required for this field.") 219 | 220 | def _construct_form(self, i, **kwargs): 221 | """ 222 | Limit the queryset of the thumbs for performance reasons (so that it doesn't 223 | pull in every available thumbnail into the selectbox) 224 | """ 225 | from cropduster.models import Thumb 226 | 227 | form = super(CropDusterInlineFormSet, self)._construct_form(i, **kwargs) 228 | 229 | field_identifier_field = form.fields['field_identifier'] 230 | field_identifier_field.widget = forms.HiddenInput() 231 | field_identifier_field.initial = self.field_identifier 232 | 233 | thumbs_field = form.fields['thumbs'] 234 | 235 | if form.instance and form.instance.pk: 236 | # Set the queryset to the current list of thumbs on the image 237 | thumbs_field.queryset = form.instance.thumbs.get_queryset() 238 | else: 239 | # Start with an empty queryset 240 | thumbs_field.queryset = Thumb.objects.none() 241 | 242 | if form.data: 243 | # Check if thumbs from POST data should be used instead. 244 | # These can differ from the values in the database if a 245 | # ValidationError elsewhere prevented saving. 246 | try: 247 | thumb_pks = [int(v) for v in form['thumbs'].value()] 248 | except (TypeError, ValueError): 249 | pass 250 | else: 251 | if thumb_pks and thumb_pks != [o.pk for o in thumbs_field.queryset]: 252 | thumbs_field.queryset = Thumb.objects.filter(pk__in=thumb_pks) 253 | 254 | return form 255 | -------------------------------------------------------------------------------- /cropduster/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import cropduster.fields 2 | import cropduster.models 3 | import django 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import cropduster.settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('contenttypes', '0002_remove_content_type_name'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Image', 20 | fields=[ 21 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 22 | ('object_id', models.PositiveIntegerField(null=True, blank=True)), 23 | ('field_identifier', models.SlugField(blank=True, default='')), 24 | ('prev_object_id', models.PositiveIntegerField(null=True, blank=True)), 25 | ('width', models.PositiveIntegerField(null=True, blank=True)), 26 | ('height', models.PositiveIntegerField(null=True, blank=True)), 27 | ('image', cropduster.fields.CropDusterSimpleImageField(db_column='path', db_index=True, height_field='height', upload_to=cropduster.models.generate_filename, width_field='width')), 28 | ('date_created', models.DateTimeField(auto_now_add=True)), 29 | ('date_modified', models.DateTimeField(auto_now=True)), 30 | ('attribution', models.CharField(max_length=255, null=True, blank=True)), 31 | ('attribution_link', models.URLField(max_length=255, null=True, blank=True)), 32 | ('caption', models.TextField(null=True, blank=True)), 33 | ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), 34 | ], 35 | options={ 36 | 'db_table': '%s_image' % cropduster.settings.CROPDUSTER_DB_PREFIX, 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='StandaloneImage', 41 | fields=[ 42 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 43 | ('md5', models.CharField(max_length=32, blank=True, default='')), 44 | ('image', cropduster.fields.CropDusterImageField(blank=True, db_column='image', default='', upload_to='')), 45 | ], 46 | options={ 47 | 'db_table': '%s_standaloneimage' % cropduster.settings.CROPDUSTER_DB_PREFIX, 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name='Thumb', 52 | fields=[ 53 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 54 | ('name', models.CharField(max_length=255, db_index=True)), 55 | ('width', models.PositiveIntegerField(default=0, null=True, blank=True)), 56 | ('height', models.PositiveIntegerField(default=0, null=True, blank=True)), 57 | ('crop_x', models.PositiveIntegerField(null=True, blank=True)), 58 | ('crop_y', models.PositiveIntegerField(null=True, blank=True)), 59 | ('crop_w', models.PositiveIntegerField(null=True, blank=True)), 60 | ('crop_h', models.PositiveIntegerField(null=True, blank=True)), 61 | ('date_modified', models.DateTimeField(auto_now=True)), 62 | ('image', models.ForeignKey(related_name='+', to='cropduster.Image', blank=True, null=True, on_delete=models.CASCADE)), 63 | ('reference_thumb', models.ForeignKey(related_name='auto_set', blank=True, to='cropduster.Thumb', null=True, on_delete=models.CASCADE)), 64 | ], 65 | options={ 66 | 'db_table': '%s_thumb' % cropduster.settings.CROPDUSTER_DB_PREFIX, 67 | }, 68 | ), 69 | ] + ([] if django.VERSION < (1, 9) else [ 70 | migrations.AddField( 71 | model_name='image', 72 | name='thumbs', 73 | field=cropduster.fields.ReverseForeignRelation(blank=True, field_name='image', serialize=False, to='cropduster.Thumb', is_migration=True), 74 | ), 75 | ]) + [ 76 | migrations.AlterUniqueTogether( 77 | name='image', 78 | unique_together=set([('content_type', 'object_id', 'field_identifier')]), 79 | ), 80 | ] 81 | -------------------------------------------------------------------------------- /cropduster/migrations/0002_alt_text.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | import cropduster.fields 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('cropduster', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name='image', 14 | name='alt_text', 15 | field=models.TextField(blank=True, default='', verbose_name='Alt Text'), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /cropduster/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/migrations/__init__.py -------------------------------------------------------------------------------- /cropduster/settings.py: -------------------------------------------------------------------------------- 1 | import math 2 | import PIL 3 | import shutil 4 | from django.conf import settings 5 | from django.core.exceptions import ImproperlyConfigured 6 | 7 | 8 | CROPDUSTER_MEDIA_ROOT = getattr(settings, 'CROPDUSTER_MEDIA_ROOT', settings.MEDIA_ROOT) 9 | 10 | try: 11 | CROPDUSTER_APP_LABEL = getattr(settings, 'CROPDUSTER_V4_APP_LABEL') 12 | except AttributeError: 13 | CROPDUSTER_APP_LABEL = getattr(settings, 'CROPDUSTER_APP_LABEL', 'cropduster') 14 | 15 | try: 16 | CROPDUSTER_DB_PREFIX = getattr(settings, 'CROPDUSTER_V4_DB_PREFIX') 17 | except AttributeError: 18 | CROPDUSTER_DB_PREFIX = getattr(settings, 'CROPDUSTER_DB_PREFIX', 'cropduster4') 19 | 20 | CROPDUSTER_PREVIEW_WIDTH = getattr(settings, 'CROPDUSTER_PREVIEW_WIDTH', 800) 21 | CROPDUSTER_PREVIEW_HEIGHT = getattr(settings, 'CROPDUSTER_PREVIEW_HEIGHT', 500) 22 | 23 | 24 | def default_jpeg_quality(width, height): 25 | p = math.sqrt(width * height) 26 | if p >= 1750: 27 | return 80 28 | elif p >= 1000: 29 | return 85 30 | else: 31 | return 90 32 | 33 | CROPDUSTER_JPEG_QUALITY = getattr(settings, 'CROPDUSTER_JPEG_QUALITY', default_jpeg_quality) 34 | 35 | 36 | def get_jpeg_quality(width, height): 37 | if callable(CROPDUSTER_JPEG_QUALITY): 38 | return CROPDUSTER_JPEG_QUALITY(width, height) 39 | elif isinstance(CROPDUSTER_JPEG_QUALITY, (int, float)): 40 | return CROPDUSTER_JPEG_QUALITY 41 | else: 42 | raise ImproperlyConfigured( 43 | "CROPDUSTER_JPEG_QUALITY setting must be either a callable " 44 | "or a numeric value, got type %s" % (type(CROPDUSTER_JPEG_QUALITY).__name__)) 45 | 46 | JPEG_SAVE_ICC_SUPPORTED = getattr(settings, 'JPEG_SAVE_ICC_SUPPORTED', True) 47 | 48 | CROPDUSTER_GIFSICLE_PATH = getattr(settings, 'CROPDUSTER_GIFSICLE_PATH', None) 49 | 50 | if CROPDUSTER_GIFSICLE_PATH is None: 51 | # Try to find executable in the PATH 52 | CROPDUSTER_GIFSICLE_PATH = shutil.which("gifsicle") 53 | 54 | CROPDUSTER_RETAIN_METADATA = getattr(settings, 'CROPDUSTER_RETAIN_METADATA', False) 55 | CROPDUSTER_CREATE_THUMBS = getattr(settings, 'CROPDUSTER_CREATE_THUMBS', True) 56 | -------------------------------------------------------------------------------- /cropduster/standalone/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/standalone/__init__.py -------------------------------------------------------------------------------- /cropduster/standalone/metadata.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import ctypes 4 | import PIL.Image 5 | import tempfile 6 | from io import open 7 | 8 | from django.core.exceptions import ImproperlyConfigured 9 | from django.core.files.storage import default_storage 10 | from django.utils.encoding import force_bytes 11 | 12 | from cropduster.files import ImageFile 13 | from cropduster.utils import json 14 | 15 | try: 16 | import libxmp 17 | except ImportError: 18 | raise ImproperlyConfigured("Could not import libxmp") 19 | except: 20 | libxmp = None 21 | # Annoyingly libxmp has a ExempiLoadError but doesn't use it, 22 | # so we need to blanket catch Exception 23 | import ctypes.util 24 | # If the library exists, raise the error triggered by the import 25 | if ctypes.util.find_library('exempi'): 26 | raise 27 | else: 28 | raise ImproperlyConfigured( 29 | "cropduster.standalone used, but exempi shared library not installed") 30 | 31 | 32 | if not libxmp: 33 | check_file_format = get_format_info = None 34 | 35 | def file_to_dict(f): 36 | return {} 37 | else: 38 | try: 39 | exempi = libxmp._exempi 40 | except AttributeError: 41 | exempi = libxmp.exempi.EXEMPI 42 | check_file_format = exempi.xmp_files_check_file_format 43 | get_format_info = exempi.xmp_files_get_format_info 44 | 45 | if not check_file_format.argtypes: 46 | check_file_format.argtypes = [ctypes.c_char_p] 47 | if not check_file_format.restype: 48 | check_file_format.restype = ctypes.c_ulong 49 | 50 | if not get_format_info.argtypes: 51 | get_format_info.argtypes = [ctypes.c_ulong, ctypes.c_void_p] 52 | if not get_format_info.restype: 53 | get_format_info.restype = ctypes.c_bool 54 | 55 | try: 56 | from libxmp.utils import file_to_dict 57 | except ImportError: 58 | from libxmp import file_to_dict 59 | 60 | 61 | class EnumerationMeta(type): 62 | 63 | def __new__(cls, name, bases, attrs): 64 | if '_lookup' not in attrs: 65 | lookup = {} 66 | for k, v in attrs.items(): 67 | if isinstance(v, int): 68 | lookup.setdefault(v, k) 69 | attrs['_lookup'] = lookup 70 | 71 | return super(EnumerationMeta, cls).__new__(cls, name, bases, attrs) 72 | 73 | def __contains__(self, value): 74 | return value in self._lookup 75 | 76 | 77 | class Enumeration(metaclass=EnumerationMeta): 78 | @classmethod 79 | def value_name(cls, value): 80 | return cls._lookup.get(value) 81 | 82 | 83 | class FormatOptions(Enumeration): 84 | 85 | XMP_FMT_CAN_INJECT_XMP = 0x0001 86 | XMP_FMT_CAN_EXPAND = 0x0002 87 | XMP_FMT_CAN_REWRITE = 0x0004 88 | XMP_FMT_PREFERS_IN_PLACE = 0x0008 89 | XMP_FMT_CAN_RECONCILE = 0x0010 90 | XMP_FMT_ALLOWS_ONLY_XMP = 0x0020 91 | XMP_FMT_RETURNS_RAW_PACKET = 0x0040 92 | XMP_FMT_HANDLER_OWNS_FILE = 0x0100 93 | XMP_FMT_ALLOW_SAFE_UPDATE = 0x0200 94 | XMP_FMT_NEEDS_READONLY_PACKET = 0x0400 95 | XMP_FMT_USE_SIDECAR_XMP = 0x0800 96 | XMP_FMT_FOLDER_BASED_FORMAT = 0x1000 97 | 98 | 99 | class FileFormats(Enumeration): 100 | 101 | XMP_FT_PDF = 0x50444620 # 'PDF ' 102 | XMP_FT_PS = 0x50532020 # 'PS ', general PostScript following DSC conventions. 103 | XMP_FT_EPS = 0x45505320 # 'EPS ', encapsulated PostScript. 104 | 105 | XMP_FT_JPEG = 0x4A504547 # 'JPEG' 106 | XMP_FT_JPEG2K = 0x4A505820 # 'JPX ', ISO 15444-1 107 | XMP_FT_TIFF = 0x54494646 # 'TIFF' 108 | XMP_FT_GIF = 0x47494620 # 'GIF ' 109 | XMP_FT_PNG = 0x504E4720 # 'PNG ' 110 | 111 | XMP_FT_SWF = 0x53574620 # 'SWF ' 112 | XMP_FT_FLA = 0x464C4120 # 'FLA ' 113 | XMP_FT_FLV = 0x464C5620 # 'FLV ' 114 | 115 | XMP_FT_MOV = 0x4D4F5620 # 'MOV ', Quicktime 116 | XMP_FT_AVI = 0x41564920 # 'AVI ' 117 | XMP_FT_CIN = 0x43494E20 # 'CIN ', Cineon 118 | XMP_FT_WAV = 0x57415620 # 'WAV ' 119 | XMP_FT_MP3 = 0x4D503320 # 'MP3 ' 120 | XMP_FT_SES = 0x53455320 # 'SES ', Audition session 121 | XMP_FT_CEL = 0x43454C20 # 'CEL ', Audition loop 122 | XMP_FT_MPEG = 0x4D504547 # 'MPEG' 123 | XMP_FT_MPEG2 = 0x4D503220 # 'MP2 ' 124 | XMP_FT_MPEG4 = 0x4D503420 # 'MP4 ', ISO 14494-12 and -14 125 | XMP_FT_WMAV = 0x574D4156 # 'WMAV', Windows Media Audio and Video 126 | XMP_FT_AIFF = 0x41494646 # 'AIFF' 127 | 128 | XMP_FT_HTML = 0x48544D4C # 'HTML' 129 | XMP_FT_XML = 0x584D4C20 # 'XML ' 130 | XMP_FT_TEXT = 0x74657874 # 'text' 131 | 132 | # Adobe application file formats. 133 | XMP_FT_PHOTOSHOP = 0x50534420 # 'PSD ' 134 | XMP_FT_ILLUSTRATOR = 0x41492020 # 'AI ' 135 | XMP_FT_INDESIGN = 0x494E4444 # 'INDD' 136 | XMP_FT_AEPROJECT = 0x41455020 # 'AEP ' 137 | XMP_FT_AEPROJTEMPLATE = 0x41455420 # 'AET ', After Effects Project Template 138 | XMP_FT_AEFILTERPRESET = 0x46465820 # 'FFX ' 139 | XMP_FT_ENCOREPROJECT = 0x4E434F52 # 'NCOR' 140 | XMP_FT_PREMIEREPROJECT = 0x5052504A # 'PRPJ' 141 | XMP_FT_PREMIERETITLE = 0x5052544C # 'PRTL' 142 | 143 | # Catch all. 144 | XMP_FT_UNKNOWN = 0x20202020 # ' ' 145 | 146 | 147 | def file_format_supported(file_path): 148 | if not os.path.exists(file_path): 149 | raise IOError("File %s could not be found" % file_path) 150 | if not check_file_format: 151 | return False 152 | file_format = check_file_format(force_bytes(os.path.abspath(file_path))) 153 | format_options = ctypes.c_int() 154 | if file_format != FileFormats.XMP_FT_UNKNOWN: 155 | format_options = get_format_info( 156 | file_format, ctypes.byref(format_options)) 157 | if isinstance(format_options, ctypes.c_int): 158 | format_options = format_options.value 159 | return bool(format_options & FormatOptions.XMP_FMT_CAN_INJECT_XMP) 160 | 161 | 162 | class MetadataDict(dict): 163 | """ 164 | Normalizes the key/values returned from libxmp.file_to_dict() 165 | into something more useful. Among the transformations: 166 | 167 | - Flattens namespaces (file_to_dict returns metadata tuples keyed on 168 | the namespaces) 169 | - Removes namespace prefixes from keys (e.g. "mwg-rs", "xmpMM", "stArea") 170 | - Expands '/' and '[index]' into dicts and lists. For example 171 | the key 'mwg-rs:Regions/mwg-rs:RegionList[1]' becomes 172 | 173 | {'Regions': {'RegionList: []}} 174 | 175 | (note that list from libxmp.file_to_dict() are 1-indexed) 176 | - Converts stDim:* and stArea:* values into ints and floats, 177 | respectively 178 | """ 179 | 180 | def __init__(self, file_path): 181 | try: 182 | # Don't use temporary files if we're using FileSystemStorage 183 | with open(default_storage.path(file_path), mode='rb') as f: 184 | self.file_path = f.name 185 | except: 186 | self.tmp_file = tempfile.NamedTemporaryFile() 187 | with default_storage.open(file_path) as f: 188 | self.tmp_file.write(f.read()) 189 | self.tmp_file.flush() 190 | self.tmp_file.seek(0) 191 | self.file_path = self.tmp_file.name 192 | ns_dict = file_to_dict(self.file_path) 193 | self.clean(ns_dict) 194 | 195 | def clean(self, ns_dict): 196 | for ns, values in ns_dict.items(): 197 | for k, v, opts in values: 198 | current = self 199 | bits = k.split('/') 200 | for bit in bits[:-1]: 201 | bit = bit.rpartition(':')[-1] 202 | m = re.search(r'^(.*)\[(\d+)\](?=\/|\Z)', bit) 203 | if not m: 204 | current = current.setdefault(bit, {}) 205 | else: 206 | bit = m.group(1) 207 | index = int(m.group(2)) - 1 208 | if isinstance(current.get(bit), list): 209 | if len(current[bit]) < (index + 1): 210 | current[bit] += [{}] * (1 + index - len(current[bit])) 211 | current = current[bit][index] 212 | else: 213 | current[bit] = [{}] * (index + 1) 214 | current = current[bit][index] 215 | 216 | if opts.get('VALUE_IS_ARRAY') and not v: 217 | v = [] 218 | elif opts.get('VALUE_IS_STRUCT') and not v: 219 | v = {} 220 | 221 | ns_prefix, sep, k = bits[-1].rpartition(':') 222 | 223 | if ns_prefix == 'stDim' and k in ('w, h'): 224 | try: 225 | v = int(v) 226 | except (TypeError, ValueError): 227 | v = None 228 | elif ns_prefix == 'stArea' and k in ('w', 'h', 'x', 'y'): 229 | try: 230 | v = float(v) 231 | except (TypeError, ValueError): 232 | v = None 233 | elif k == 'DerivedFrom' and isinstance(v, str): 234 | v = re.sub(r'^xmp\.did:', '', v).lower() 235 | elif ns_prefix == 'crop' and k == 'json': 236 | v = json.loads(v) 237 | elif ns_prefix == 'crop' and k == 'md5': 238 | v = v.lower() 239 | 240 | m = re.search(r'^(.*)\[(\d+)\](?=\/|\Z)', k) 241 | if m: 242 | current = current.setdefault(m.group(1), [{}]) 243 | k = int(m.group(2)) - 1 244 | 245 | if isinstance(current, list) and isinstance(k, int): 246 | if len(current) <= k: 247 | current.append(*([{}] * (1 + k - len(current)))) 248 | 249 | # Now assign value to current position 250 | try: 251 | current[k] = v 252 | except TypeError: # Special-case if current isn't a dict. 253 | current = {k: v} 254 | except IndexError: 255 | if k != 0 or not isinstance(current, list): 256 | raise 257 | current = [v] 258 | 259 | @property 260 | def crop_size(self): 261 | from cropduster.resizing import Size 262 | size_json = self.get('size', {}).get('json') or None 263 | if size_json: 264 | return size_json 265 | size_w = self.get('size', {}).get('w') or None 266 | size_h = self.get('size', {}).get('h') or None 267 | if not size_w and not size_h: 268 | return None 269 | return Size('crop', w=size_w, h=size_h) 270 | 271 | @property 272 | def crop_thumb(self): 273 | from cropduster.models import Thumb 274 | 275 | try: 276 | pil_img = PIL.Image.open(self.file_path) 277 | except: 278 | return None 279 | orig_w, orig_h = pil_img.size 280 | dimensions = self.get('Regions', {}).get('AppliedToDimensions', None) 281 | 282 | if not isinstance(dimensions, dict): 283 | return None 284 | w, h = dimensions.get('w'), dimensions.get('h') 285 | if not all(map(lambda v: isinstance(v, int), [w, h])): 286 | return None 287 | region_list = self.get('Regions', {}).get('RegionList', []) 288 | if not isinstance(region_list, list): 289 | return None 290 | try: 291 | crop_region = [r for r in region_list if r['Name'] == 'Crop'][0] 292 | except IndexError: 293 | return None 294 | if not isinstance(crop_region, dict) or not isinstance(crop_region.get('Area'), dict): 295 | return None 296 | area = crop_region.get('Area') 297 | # Verify that all crop area coordinates are floats 298 | if not all([isinstance(v, float) for k, v in area.items() if k in ('w', 'h', 'x', 'y')]): 299 | return None 300 | 301 | return Thumb( 302 | name="crop", 303 | crop_x=area['x'] * w, 304 | crop_y=area['y'] * h, 305 | crop_w=area['w'] * w, 306 | crop_h=area['h'] * h, 307 | width=orig_w, 308 | height=orig_h) 309 | 310 | 311 | class MetadataImageFile(ImageFile): 312 | 313 | def __init__(self, *args, **kwargs): 314 | super(MetadataImageFile, self).__init__(*args, **kwargs) 315 | if self: 316 | self.metadata = MetadataDict(self.name) 317 | 318 | 319 | def get_xmp_from_file(file_path): 320 | return libxmp.XMPFiles(file_path=file_path).get_xmp() 321 | 322 | 323 | def get_xmp_from_bytes(img_bytes): 324 | tmp = tempfile.NamedTemporaryFile(delete=False) 325 | tmp.write(img_bytes) 326 | tmp.close() 327 | try: 328 | return get_xmp_from_file(tmp.name) 329 | finally: 330 | os.unlink(tmp.name) 331 | 332 | 333 | def get_xmp_from_storage(file_path, storage=default_storage): 334 | with storage.open(file_path, mode='rb') as f: 335 | return get_xmp_from_bytes(f.read()) 336 | 337 | 338 | def put_xmp_to_file(xmp_meta, file_path): 339 | xmp_file = libxmp.XMPFiles(file_path=file_path, open_forupdate=True) 340 | 341 | if not xmp_file.can_put_xmp(xmp_meta): 342 | if not file_format_supported(file_path): 343 | raise Exception("Image format of %s does not allow metadata" % (file_path)) 344 | else: 345 | raise Exception("Could not add metadata to image %s" % (file_path)) 346 | 347 | xmp_file.put_xmp(xmp_meta) 348 | xmp_file.close_file() 349 | 350 | 351 | def put_xmp_to_storage(xmp_meta, file_path, storage=default_storage): 352 | tmp = tempfile.NamedTemporaryFile(delete=False) 353 | try: 354 | with default_storage.open(file_path, mode='rb') as f: 355 | tmp.write(f.read()) 356 | tmp.close() 357 | put_xmp_to_file(xmp_meta, tmp.name) 358 | with open(tmp.name, mode='rb') as f: 359 | data = f.read() 360 | with storage.open(file_path, mode='wb') as f: 361 | f.write(data) 362 | finally: 363 | os.unlink(tmp.name) 364 | -------------------------------------------------------------------------------- /cropduster/standalone/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import hashlib 3 | 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.files.uploadedfile import SimpleUploadedFile 6 | from django.db import models 7 | 8 | from generic_plus.utils import get_relative_media_url 9 | 10 | from cropduster import settings as cropduster_settings 11 | from cropduster.fields import CropDusterField 12 | from cropduster.files import VirtualFieldFile 13 | from cropduster.resizing import Size 14 | 15 | 16 | class StandaloneImageManager(models.Manager): 17 | 18 | def get_from_file(self, file_path, upload_to=None, preview_w=None, preview_h=None): 19 | from cropduster.models import Image 20 | from cropduster.views.forms import clean_upload_data 21 | 22 | image_file = VirtualFieldFile(file_path) 23 | md5 = hashlib.md5() 24 | image_contents = image_file.read() 25 | md5.update(image_contents) 26 | basepath, basename = os.path.split(file_path) 27 | basefile, extension = os.path.splitext(basename) 28 | if basefile == 'original': 29 | basepath, basename = os.path.split(basepath) 30 | basename += extension 31 | standalone, created = self.get_or_create(md5=md5.hexdigest().lower()) 32 | if created or not standalone.image: 33 | file_data = clean_upload_data({ 34 | 'image': SimpleUploadedFile(basename, image_contents), 35 | 'upload_to': upload_to, 36 | }) 37 | file_path = get_relative_media_url(file_data['image'].name) 38 | standalone.image = file_path 39 | standalone.save() 40 | else: 41 | file_path = get_relative_media_url(standalone.image.name) 42 | 43 | cropduster_image, created = Image.objects.get_or_create( 44 | content_type=ContentType.objects.get_for_model(StandaloneImage), 45 | object_id=standalone.pk) 46 | standalone.image.related_object = cropduster_image 47 | cropduster_image.image = file_path 48 | cropduster_image.save() 49 | cropduster_image.save_preview(preview_w, preview_h) 50 | return standalone 51 | 52 | 53 | class StandaloneImage(models.Model): 54 | 55 | objects = StandaloneImageManager() 56 | 57 | md5 = models.CharField(max_length=32, blank=True, default='') 58 | image = CropDusterField(sizes=[Size("crop")], upload_to='') 59 | 60 | class Meta: 61 | app_label = cropduster_settings.CROPDUSTER_APP_LABEL 62 | db_table = '%s_standaloneimage' % cropduster_settings.CROPDUSTER_DB_PREFIX 63 | 64 | def save(self, **kwargs): 65 | if not self.md5 and self.image: 66 | md5_hash = hashlib.md5() 67 | with self.image.related_object.image_file_open() as f: 68 | md5_hash.update(f.read()) 69 | self.md5 = md5_hash.hexdigest() 70 | super(StandaloneImage, self).save(**kwargs) 71 | -------------------------------------------------------------------------------- /cropduster/standalone/static/ckeditor/ckeditor-dev: -------------------------------------------------------------------------------- 1 | ckeditor -------------------------------------------------------------------------------- /cropduster/standalone/static/ckeditor/ckeditor/plugins/cropduster/icons/cropduster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/standalone/static/ckeditor/ckeditor/plugins/cropduster/icons/cropduster.png -------------------------------------------------------------------------------- /cropduster/standalone/static/ckeditor/ckeditor/plugins/cropduster/lang/en.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2003-2013, CKSource - Frederico Knabben. All rights reserved. 3 | For licensing, see LICENSE.md or http://ckeditor.com/license 4 | */ 5 | CKEDITOR.plugins.setLang( 'cropduster', 'en', { 6 | alt: 'Alternative Text', // Inherit from image plugin. 7 | captioned: 'Captioned image', // NEW property. 8 | lockRatio: 'Lock Ratio', // Inherit from image plugin. 9 | menu: 'Image Properties', // Inherit from image plugin. 10 | resetSize: 'Reset Size', // Inherit from image plugin. 11 | resizer: 'Click and drag to resize', // NEW property. 12 | title: 'Image Properties', // Inherit from image plugin. 13 | urlMissing: 'Image source URL is missing.' // Inherit from image plugin. 14 | } ); 15 | -------------------------------------------------------------------------------- /cropduster/standalone/views.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | from django.views.decorators.clickjacking import xframe_options_exempt 3 | 4 | from cropduster.models import Size, Thumb 5 | from cropduster.standalone.metadata import MetadataImageFile 6 | from cropduster.views import CropDusterIndex 7 | from cropduster.views.utils import FakeQuerySet 8 | 9 | from .models import StandaloneImage 10 | 11 | 12 | class CropDusterStandaloneIndex(CropDusterIndex): 13 | 14 | is_standalone = True 15 | 16 | @cached_property 17 | def image_file(self): 18 | (preview_w, preview_h) = self.preview_size 19 | return MetadataImageFile(self.request.GET.get('image'), 20 | upload_to=self.upload_to, 21 | preview_w=preview_w, 22 | preview_h=preview_h) 23 | 24 | @cached_property 25 | def db_image(self): 26 | if not self.image_file: 27 | return None 28 | md5 = self.image_file.metadata.get('md5') or self.image_file.metadata.get('DerivedFrom') 29 | try: 30 | standalone = StandaloneImage.objects.get(md5=md5) 31 | except StandaloneImage.DoesNotExist: 32 | (preview_w, preview_h) = self.preview_size 33 | standalone = StandaloneImage.objects.get_from_file(self.image_file.name, 34 | upload_to=self.upload_to, preview_w=preview_w, preview_h=preview_h) 35 | db_image = standalone.image.related_object 36 | if not getattr(db_image, 'pk', None): 37 | raise Exception("Image does not exist in database") 38 | if not db_image.image and standalone.image.name: 39 | db_image.image = standalone.image.name 40 | db_image.save() 41 | db_image.save_preview(preview_w=self.preview_size[0], preview_h=self.preview_size[1]) 42 | return db_image 43 | 44 | @cached_property 45 | def max_w(self): 46 | try: 47 | max_w = int(self.request.GET.get('max_w')) or None 48 | except (TypeError, ValueError): 49 | pass 50 | else: 51 | orig_w = getattr(self.orig_image, 'width', None) or 0 52 | if not orig_w or max_w < orig_w: 53 | return max_w 54 | return None 55 | 56 | @cached_property 57 | def sizes(self): 58 | size = getattr(self.image_file.metadata, 'crop_size', None) 59 | if not size: 60 | size = Size('crop', max_w=self.max_w) 61 | else: 62 | size.max_w = self.max_w 63 | return [size] 64 | 65 | @cached_property 66 | def thumbs(self): 67 | if getattr(self.image_file.metadata, 'crop_thumb', None): 68 | thumb = self.image_file.metadata.crop_thumb 69 | else: 70 | orig_w, orig_h = self.image_file.dimensions 71 | thumb = Thumb(name="crop", 72 | crop_x=0, crop_y=0, crop_w=orig_w, crop_h=orig_h, 73 | width=orig_w, height=orig_h) 74 | 75 | if orig_w and self.max_w: 76 | thumb.width = self.max_w 77 | thumb.height = int(round((orig_h / orig_w) * self.max_w)) 78 | return FakeQuerySet([thumb], Thumb.objects.none()) 79 | 80 | @cached_property 81 | def orig_image(self): 82 | return self.image_file.get_for_size('original') 83 | 84 | 85 | index = xframe_options_exempt(CropDusterStandaloneIndex.as_view()) 86 | -------------------------------------------------------------------------------- /cropduster/static/cropduster/css/cropduster.css: -------------------------------------------------------------------------------- 1 | .cropduster-form .grp-module { 2 | background: transparent; 3 | } 4 | 5 | .cropduster-form .image, 6 | .cropduster-form .thumbs, 7 | .cropduster-form .DELETE, 8 | .cropduster-form .field_identifier, 9 | .cropduster-form .field-image, 10 | .cropduster-form .field-thumbs, 11 | .cropduster-form .field-field_identifier, 12 | .cropduster-form .inline-related .grp-collapse-handler { 13 | display: none; 14 | } 15 | .cropduster-form .inline-related, 16 | .cropduster-form .inline-related .grp-module, 17 | .cropduster-form .inline-related .grp-row, 18 | .cropduster-form .inline-related .form-row { 19 | border: 0 !important; 20 | } 21 | 22 | .cropduster-form .inline-related .grp-module, 23 | .cropduster-form .inline-related .module { 24 | clear: both; 25 | } 26 | 27 | .cropduster-form .inline-related .grp-row input { 28 | width: 588px; 29 | } 30 | .cropduster-form { 31 | border: 0 !important; 32 | } 33 | 34 | body.cropduster-debug .cropduster-form .image, 35 | body.cropduster-debug .cropduster-form .thumbs, 36 | body.cropduster-debug .cropduster-form .field-image, 37 | body.cropduster-debug .cropduster-form .field-thumbs, 38 | body.cropduster-debug .cropduster-form .DELETE, 39 | body.cropduster-debug .cropduster-form .inline-related .grp-collapse-handler { 40 | display: block !important; 41 | } 42 | 43 | .cropduster-customfield { 44 | display: block; 45 | float: left; 46 | } 47 | 48 | .cropduster-form .delete { 49 | display: block; 50 | float: left; 51 | margin-left: 20px; 52 | margin-top: 5px; 53 | } 54 | 55 | .cropduster-customfield img { 56 | border: 0; 57 | } 58 | 59 | .cropduster-preview { 60 | _display: inline; 61 | display: inline-block; 62 | } 63 | 64 | /* buttons */ 65 | .cropduster-upload-form .cropduster-button, 66 | .cropduster-upload-form .cropduster-button:visited, 67 | .cropduster-upload-form .cropduster-button:hover, 68 | .cropduster-upload-form .cropduster-button:active { 69 | margin: 0; 70 | display: inline-block; 71 | padding: 6px 10px 7px 10px; 72 | text-decoration: none; 73 | -moz-border-radius: 6px; 74 | -webkit-border-radius: 6px; 75 | border-radius: 6px; 76 | border-bottom: 1px solid rgba(0,0,0,0.25); 77 | position: relative; 78 | cursor: pointer; 79 | user-select: none; 80 | -webkit-user-select: none; 81 | -khtml-user-select: none; 82 | -o-user-select: none; 83 | -moz-user-select: none; 84 | -webkit-font-smoothing: subpixel-antialiased !important; 85 | background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#ECECEC), to(#B6B6B6)); 86 | background: -moz-linear-gradient(top, #ECECEC, #B6B6B6); 87 | background-color: #ECECEC; 88 | border: 1px solid rgba(0, 0, 0, 0.3); /* #a3a3a3 */ 89 | border-bottom: 1px solid rgba(0, 0, 0, 0.4); /* #9a9a9a */ 90 | color: #000; 91 | -webkit-text-shadow: rgba(255,255,255,0.5) 0 1px 0; 92 | -moz-text-shadow: rgba(255,255,255,0.5) 0 1px 0; 93 | text-shadow: rgba(255,255,255,0.5) 0 1px 0; 94 | font-size: 12px; 95 | font-weight: bold; 96 | line-height: 1; 97 | } 98 | 99 | .cropduster-upload-form .cropduster-button:hover { 100 | background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#f4f4f4), to(#CACACA)); 101 | background: -moz-linear-gradient(top, #f4f4f4, #CACACA); 102 | background-color: #f4f4f4; 103 | } 104 | .cropduster-upload-form .cropduster-button:active { 105 | background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#D6D6D6), to(#f4f4f4)); 106 | background: -moz-linear-gradient(top, #D6D6D6, #f4f4f4); 107 | background-color: #D6D6D6; 108 | border: 1px solid rgba(0, 0, 0, 0.4); /* #9a9a9a */ 109 | border-bottom: 1px solid rgba(0, 0, 0, 0.36); /* #a3a3a3 */ 110 | padding: 7px 10px 6px 10px; 111 | -webkit-text-shadow: rgba(255,255,255,0.5) 0 -1px 0; 112 | -moz-text-shadow: rgba(255,255,255,0.5) 0 -1px 0; 113 | text-shadow: rgba(255,255,255,0.5) 0 -1px 0; 114 | } 115 | 116 | .cropduster-upload-form .cropduster-button.disabled, 117 | .cropduster-upload-form .cropduster-button.disabled:hover, 118 | .cropduster-upload-form .cropduster-button.disabled:active { 119 | color: #888; 120 | cursor: default; 121 | background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#ECECEC), to(#d1d1d1)); 122 | background: -moz-linear-gradient(top, #ECECEC, #d1d1d1); 123 | background-color: #ECECEC; 124 | border: 1px solid rgba(0, 0, 0, 0.36); /* #a3a3a3 */ 125 | border-bottom: 1px solid rgba(0, 0, 0, 0.4); /* #9a9a9a */ 126 | outline: none; 127 | } 128 | 129 | .cropduster-upload-form .cropduster-button.small, 130 | .cropduster-upload-form .cropduster-button.small:visited, 131 | .cropduster-upload-form .cropduster-button.small:hover { 132 | padding: 3px 6px 4px 6px; 133 | height: 23px; 134 | } 135 | 136 | .cropduster-upload-form .cropduster-button.small:active { 137 | padding: 4px 6px 3px 6px; 138 | height: 23px; 139 | } 140 | 141 | .cropduster-image-thumb { 142 | display: block; 143 | -webkit-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.8); 144 | -moz-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.8); 145 | box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.8); 146 | } 147 | 148 | .cropduster-form .cropduster-images.thumbs { 149 | display: block; 150 | } 151 | .cropduster-images .cropduster-image { 152 | display: none; 153 | } 154 | .cropduster-images .cropduster-image:first-child { 155 | display: block; 156 | } 157 | 158 | #content.colM .cropduster-form .module.aligned { 159 | margin-left: 8em; 160 | padding-left: 10px; 161 | } 162 | #content.colM .cropduster-form.nested-inline-form .module.aligned { 163 | margin-left: 0; 164 | } 165 | -------------------------------------------------------------------------------- /cropduster/static/cropduster/css/jcrop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/css/jcrop.gif -------------------------------------------------------------------------------- /cropduster/static/cropduster/css/jquery.jcrop.css: -------------------------------------------------------------------------------- 1 | /* jquery.Jcrop.css v0.9.12 - MIT License */ 2 | /* 3 | The outer-most container in a typical Jcrop instance 4 | If you are having difficulty with formatting related to styles 5 | on a parent element, place any fixes here or in a like selector 6 | 7 | You can also style this element if you want to add a border, etc 8 | A better method for styling can be seen below with .jcrop-light 9 | (Add a class to the holder and style elements for that extended class) 10 | */ 11 | .jcrop-holder { 12 | direction: ltr; 13 | text-align: left; 14 | /* IE10 touch compatibility */ 15 | -ms-touch-action: none; 16 | } 17 | /* Selection Border */ 18 | .jcrop-vline, 19 | .jcrop-hline { 20 | background: #ffffff url("jcrop.gif"); 21 | font-size: 0; 22 | position: absolute; 23 | } 24 | .jcrop-vline { 25 | height: 100%; 26 | width: 1px !important; 27 | } 28 | .jcrop-vline.right { 29 | right: 0; 30 | } 31 | .jcrop-hline { 32 | height: 1px !important; 33 | width: 100%; 34 | } 35 | .jcrop-hline.bottom { 36 | bottom: 0; 37 | } 38 | /* Invisible click targets */ 39 | .jcrop-tracker { 40 | height: 100%; 41 | width: 100%; 42 | /* "turn off" link highlight */ 43 | -webkit-tap-highlight-color: transparent; 44 | /* disable callout, image save panel */ 45 | -webkit-touch-callout: none; 46 | /* disable cut copy paste */ 47 | -webkit-user-select: none; 48 | } 49 | /* Selection Handles */ 50 | .jcrop-handle { 51 | background-color: #333333; 52 | border: 1px #eeeeee solid; 53 | width: 7px; 54 | height: 7px; 55 | font-size: 1px; 56 | } 57 | .jcrop-handle.ord-n { 58 | left: 50%; 59 | margin-left: -4px; 60 | margin-top: -4px; 61 | top: 0; 62 | } 63 | .jcrop-handle.ord-s { 64 | bottom: 0; 65 | left: 50%; 66 | margin-bottom: -4px; 67 | margin-left: -4px; 68 | } 69 | .jcrop-handle.ord-e { 70 | margin-right: -4px; 71 | margin-top: -4px; 72 | right: 0; 73 | top: 50%; 74 | } 75 | .jcrop-handle.ord-w { 76 | left: 0; 77 | margin-left: -4px; 78 | margin-top: -4px; 79 | top: 50%; 80 | } 81 | .jcrop-handle.ord-nw { 82 | left: 0; 83 | margin-left: -4px; 84 | margin-top: -4px; 85 | top: 0; 86 | } 87 | .jcrop-handle.ord-ne { 88 | margin-right: -4px; 89 | margin-top: -4px; 90 | right: 0; 91 | top: 0; 92 | } 93 | .jcrop-handle.ord-se { 94 | bottom: 0; 95 | margin-bottom: -4px; 96 | margin-right: -4px; 97 | right: 0; 98 | } 99 | .jcrop-handle.ord-sw { 100 | bottom: 0; 101 | left: 0; 102 | margin-bottom: -4px; 103 | margin-left: -4px; 104 | } 105 | /* Dragbars */ 106 | .jcrop-dragbar.ord-n, 107 | .jcrop-dragbar.ord-s { 108 | height: 7px; 109 | width: 100%; 110 | } 111 | .jcrop-dragbar.ord-e, 112 | .jcrop-dragbar.ord-w { 113 | height: 100%; 114 | width: 7px; 115 | } 116 | .jcrop-dragbar.ord-n { 117 | margin-top: -4px; 118 | } 119 | .jcrop-dragbar.ord-s { 120 | bottom: 0; 121 | margin-bottom: -4px; 122 | } 123 | .jcrop-dragbar.ord-e { 124 | margin-right: -4px; 125 | right: 0; 126 | } 127 | .jcrop-dragbar.ord-w { 128 | margin-left: -4px; 129 | } 130 | /* The "jcrop-light" class/extension */ 131 | .jcrop-light .jcrop-vline, 132 | .jcrop-light .jcrop-hline { 133 | background: #ffffff; 134 | filter: alpha(opacity=70) !important; 135 | opacity: .70!important; 136 | } 137 | .jcrop-light .jcrop-handle { 138 | -moz-border-radius: 3px; 139 | -webkit-border-radius: 3px; 140 | background-color: #000000; 141 | border-color: #ffffff; 142 | border-radius: 3px; 143 | } 144 | /* The "jcrop-dark" class/extension */ 145 | .jcrop-dark .jcrop-vline, 146 | .jcrop-dark .jcrop-hline { 147 | background: #000000; 148 | filter: alpha(opacity=70) !important; 149 | opacity: 0.7 !important; 150 | } 151 | .jcrop-dark .jcrop-handle { 152 | -moz-border-radius: 3px; 153 | -webkit-border-radius: 3px; 154 | background-color: #ffffff; 155 | border-color: #000000; 156 | border-radius: 3px; 157 | } 158 | /* Simple macro to turn off the antlines */ 159 | .solid-line .jcrop-vline, 160 | .solid-line .jcrop-hline { 161 | background: #ffffff; 162 | } 163 | /* Fix for twitter bootstrap et al. */ 164 | .jcrop-holder img, 165 | img.jcrop-preview { 166 | max-width: none; 167 | } 168 | -------------------------------------------------------------------------------- /cropduster/static/cropduster/css/upload.css: -------------------------------------------------------------------------------- 1 | body.cropduster-upload-form.grp-popup #grp-content { 2 | top: 0; 3 | } 4 | 5 | body.cropduster-upload-form .module, body.cropduster-upload-form .grp-module { 6 | float: none; 7 | } 8 | 9 | body.cropduster-upload-form ul.messagelist, 10 | body.cropduster-upload-form ul.grp-messagelist { 11 | overflow: hidden; 12 | } 13 | 14 | body.cropduster-upload-form #crop-form { 15 | margin-top: 15px; 16 | } 17 | 18 | body.cropduster-upload-form #error-container { 19 | margin-top: 10px; 20 | } 21 | 22 | body.cropduster-upload-form ul.errorlist { 23 | color: #fff; 24 | } 25 | 26 | body.cropduster-upload-form .errornote > ul.errorlist > li { 27 | overflow: hidden; 28 | position: relative; 29 | } 30 | 31 | body.cropduster-upload-form .errornote .error-field { 32 | display: block; 33 | width: 80px; 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | } 38 | 39 | body.cropduster-upload-form .errornote .error-field:after { 40 | content: ":"; 41 | } 42 | body.cropduster-upload-form .errornote .error-field.error-__all__ { 43 | display: none; 44 | } 45 | body.cropduster-upload-form .errornote .error-field.error-__all__ + ul.errorlist { 46 | padding-left: 0; 47 | } 48 | 49 | body.cropduster-upload-form ul.errorlist ul.errorlist { 50 | float: left; 51 | padding-left: 100px; 52 | margin: 0; 53 | } 54 | 55 | body.cropduster-upload-form .submit-button-container { 56 | border: 0 !important; 57 | box-shadow: none !important; 58 | -webkit-box-shadow: none !important; 59 | -moz-box-shadow: none !important; 60 | background: transparent !important; 61 | -webkit-border-radius: 0 !important; 62 | -moz-border-radius: 0 !important; 63 | border-radius: 0 !important; 64 | } 65 | 66 | body.cropduster-upload-form .module.footer, 67 | body.cropduster-upload-form .grp-module.grp-footer, 68 | body.cropduster-upload-form .grp-module.grp-fixed-footer { 69 | border-top: 1px solid #111; 70 | } 71 | 72 | body.cropduster-upload-form #current-thumb-info { 73 | display: none; 74 | float:left; 75 | margin-left:5px; 76 | line-height:27px; 77 | position:relative; 78 | } 79 | body.cropduster-upload-form #current-thumb-info > div { 80 | position: absolute; 81 | top: 0; 82 | left: 0; 83 | } 84 | body.cropduster-upload-form #current-thumb-info > div#current-thumb-index { 85 | width:14px; 86 | } 87 | body.cropduster-upload-form #current-thumb-info > div#current-thumb-index { 88 | position:absolute; 89 | left:17px; 90 | width:14px; 91 | } 92 | body.cropduster-upload-form #current-thumb-info > div#current-thumb-index:after { 93 | content: "/"; 94 | position: absolute; 95 | left: 17px; 96 | top: 0; 97 | width: 5px; 98 | } 99 | body.cropduster-upload-form #current-thumb-info > div#thumb-total-count { 100 | position: absolute; 101 | left: 44px; 102 | top: 0; 103 | width: 14px; 104 | } 105 | body.cropduster-upload-form #current-thumb-info > div#current-thumb-label { 106 | left: 70px; 107 | width: 200px; 108 | overflow: hidden; 109 | text-overflow: ellipsis; 110 | -o-text-overflow: ellipsis; 111 | } 112 | #crop-nav { 113 | overflow: hidden; 114 | float: left; 115 | margin-top: 2px; 116 | display: none; 117 | } 118 | #thumb-formset-container { 119 | display: none; 120 | } 121 | form#upload .row.hidden { 122 | display: none; 123 | } 124 | body.cropduster-debug #thumb-formset-container { 125 | display: block; 126 | } 127 | body.cropduster-debug form#upload .row.hidden { 128 | display: block; 129 | } 130 | #nav-left, #nav-right { 131 | float: left; 132 | text-align: center; 133 | text-indent: -10000px; 134 | } 135 | #nav-left span, #nav-right span { 136 | display: block; 137 | width: 25px; 138 | height: 12px; 139 | background: transparent url(../img/arrows.png) no-repeat 0 0; 140 | } 141 | #nav-left span { background-position: 0 -6px; } 142 | #nav-right span { background-position: -25px -6px; } 143 | #nav-left.disabled span { background-position: 0 -31px; } 144 | #nav-right.disabled span { background-position: -25px -31px; } 145 | #nav-left { 146 | border-right: 1px solid #000; 147 | } 148 | body.cropduster-upload-form #nav-left.cropduster-button, 149 | body.cropduster-upload-form #nav-left.cropduster-button:active, 150 | body.cropduster-upload-form #nav-left.cropduster-button:hover { 151 | width: 25px; 152 | -webkit-border-top-left-radius: 5px; 153 | -webkit-border-top-right-radius: 0; 154 | -webkit-border-bottom-left-radius: 5px; 155 | -webkit-border-bottom-right-radius: 0; 156 | -moz-border-radius-topleft: 5px; 157 | -moz-border-radius-bottomleft: 5px; 158 | -moz-border-radius-topright: 0; 159 | -moz-border-radius-bottomright: 0; 160 | border-top-left-radius: 5px; 161 | border-bottom-left-radius: 5px; 162 | border-top-right-radius: 0; 163 | border-bottom-right-radius: 0; 164 | border-right: 1px solid #000; 165 | margin: 0; 166 | padding: 4px 5px 5px 5px !important; 167 | } 168 | body.cropduster-upload-form #nav-right.cropduster-button, 169 | body.cropduster-upload-form #nav-right.cropduster-button:active, 170 | body.cropduster-upload-form #nav-right.cropduster-button:hover { 171 | width: 25px; 172 | -webkit-border-top-right-radius: 5px; 173 | -webkit-border-bottom-right-radius: 5px; 174 | -webkit-border-top-left-radius: 0; 175 | -webkit-border-bottom-left-radius: 0; 176 | -moz-border-radius-topright: 5px; 177 | -moz-border-radius-bottomright: 5px; 178 | -moz-border-radius-topleft: 0; 179 | -moz-border-radius-bottomleft: 0; 180 | border-top-right-radius: 5px; 181 | border-bottom-right-radius: 5px; 182 | border-top-left-radius: 0; 183 | border-bottom-left-radius: 0; 184 | margin: 0; 185 | padding: 4px 5px 5px 5px !important; 186 | } 187 | body.cropduster-upload-form #nav-left.cropduster-button:active, 188 | body.cropduster-upload-form #nav-right.cropduster-button:active { 189 | padding: 5px 5px 4px 5px !important; 190 | } 191 | 192 | body.cropduster-upload-form h1#step-header { 193 | margin: 0 0 8px; 194 | padding: 15px 0 5px; 195 | } 196 | 197 | body.cropduster-upload-form #content-main { 198 | margin-top: 12px; 199 | } 200 | 201 | body.cropduster-upload-form form#size .row.width, 202 | body.cropduster-upload-form form#size .row.height { 203 | display: none; 204 | } 205 | 206 | body.cropduster-upload-form form#size .row { 207 | width: 50%; 208 | float: left; 209 | padding: 8px 0; 210 | clear: none; 211 | } 212 | body.cropduster-upload-form form#size .row, 213 | body.cropduster-upload-form form#size .row:first-child, 214 | body.cropduster-upload-form form#size .row:last-child { 215 | border-top: 1px solid #FFFFFF !important; 216 | border-bottom: 0 !important; 217 | } 218 | 219 | body.cropduster-upload-form form#size .span-4 { 220 | width: 45px; 221 | } 222 | body.cropduster-upload-form form#size .row:first-child .span-4 { 223 | padding-left: 15px; 224 | } 225 | 226 | body.cropduster-upload-form form#size .span-4 + .span-flexible { 227 | margin-left: 55px; 228 | } 229 | body.cropduster-upload-form form#size input[type="text"] { 230 | width: 80px; 231 | } 232 | 233 | body.cropduster-upload-form.cropduster-standalone .module.footer { 234 | /* height: 30px;*/ 235 | display: none; 236 | } 237 | body.cropduster-upload-form.cropduster-standalone #content-main { 238 | margin-top: 0; 239 | } 240 | body.cropduster-upload-form.cropduster-standalone #content-inner { 241 | padding: 0; 242 | } 243 | body.cropduster-upload-form.cropduster-standalone form#upload { 244 | position: relative; 245 | } 246 | body.cropduster-upload-form.cropduster-standalone form#upload #upload-button, 247 | body.cropduster-upload-form.cropduster-standalone form#upload #reupload-button { 248 | display: none; 249 | position: absolute; 250 | top: 5px; 251 | right: 5px; 252 | } 253 | body.cropduster-upload-form.cropduster-standalone #grp-content #content-inner { 254 | bottom: 0; 255 | } 256 | 257 | /* Django styles without grappelli */ 258 | body.cropduster-upload-form #container { 259 | position: absolute; 260 | height: 100%; 261 | } 262 | body.cropduster-upload-form #content.colM { 263 | position: absolute; 264 | width: 100%; 265 | height: 100%; 266 | margin: 0; 267 | top: 0; 268 | bottom: 0; 269 | left: 0; 270 | right: 0; 271 | padding: 0 15px 10px 15px; 272 | box-sizing: border-box; 273 | -moz-box-sizing: border-box; 274 | } 275 | body.cropduster-upload-form #content.colM footer { 276 | border: 0; 277 | border-top: 1px solid #111; 278 | position: absolute; 279 | width: auto; 280 | height: 34px; 281 | bottom: 0; 282 | left: 0; 283 | right: 0; 284 | margin: 0; 285 | padding: 2px 5px; 286 | line-height: 17px; 287 | color: #fff; 288 | background: #333; 289 | background: -moz-linear-gradient(top, #444, #333); 290 | background: -webkit-gradient(linear, left top, left bottom, from(#444), to(#333)); 291 | background: -o-linear-gradient(top, #444, #333); 292 | box-sizing: border-box; 293 | -moz-box-sizing: border-box; 294 | -webkit-box-sizing: border-box; 295 | } 296 | body.cropduster-upload-form #content.colM footer ul, 297 | body.cropduster-upload-form #content.colM footer ul li { 298 | margin: 0; 299 | padding: 0; 300 | list-style-type: none; 301 | } 302 | body.cropduster-upload-form #content.colM footer ul li { 303 | float: right; 304 | margin-left: 10px; 305 | } 306 | 307 | /* Help Text: Minimum image size*/ 308 | body.cropduster-upload-form #upload-min-size-help { 309 | float:right; 310 | } 311 | -------------------------------------------------------------------------------- /cropduster/static/cropduster/img/arrows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/img/arrows.png -------------------------------------------------------------------------------- /cropduster/static/cropduster/img/blank.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/img/blank.gif -------------------------------------------------------------------------------- /cropduster/static/cropduster/img/cropduster_icon_upload_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/img/cropduster_icon_upload_hover.png -------------------------------------------------------------------------------- /cropduster/static/cropduster/img/cropduster_icon_upload_select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/img/cropduster_icon_upload_select.png -------------------------------------------------------------------------------- /cropduster/static/cropduster/img/progressbar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/static/cropduster/img/progressbar.gif -------------------------------------------------------------------------------- /cropduster/static/cropduster/js/jquery.class.js: -------------------------------------------------------------------------------- 1 | // Inspired by base2 and Prototype 2 | (function(){ 3 | var initializing = false, fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/; 4 | 5 | // The base Class implementation (does nothing) 6 | this.Class = function(){}; 7 | 8 | // Create a new Class that inherits from this class 9 | Class.extend = function(prop) { 10 | var _super = this.prototype; 11 | 12 | // Instantiate a base class (but only create the instance, 13 | // don't run the init constructor) 14 | initializing = true; 15 | var prototype = new this(); 16 | initializing = false; 17 | 18 | // Copy the properties over onto the new prototype 19 | for (var name in prop) { 20 | // Check if we're overwriting an existing function 21 | prototype[name] = typeof prop[name] == "function" && 22 | typeof _super[name] == "function" && fnTest.test(prop[name]) ? 23 | (function(name, fn){ 24 | return function() { 25 | var tmp = this._super; 26 | 27 | // Add a new ._super() method that is the same method 28 | // but on the super-class 29 | this._super = _super[name]; 30 | 31 | // The method only need to be bound temporarily, so we 32 | // remove it when we're done executing 33 | var ret = fn.apply(this, arguments); 34 | this._super = tmp; 35 | 36 | return ret; 37 | }; 38 | })(name, prop[name]) : 39 | prop[name]; 40 | } 41 | 42 | // The dummy class constructor 43 | function Class() { 44 | // All construction is actually done in the init method 45 | if ( !initializing && this.init ) 46 | this.init.apply(this, arguments); 47 | } 48 | 49 | // Populate our constructed prototype object 50 | Class.prototype = prototype; 51 | 52 | // Enforce the constructor to be what we expect 53 | Class.constructor = Class; 54 | 55 | // And make this class extendable 56 | Class.extend = arguments.callee; 57 | 58 | return Class; 59 | }; 60 | })(); -------------------------------------------------------------------------------- /cropduster/templates/cropduster/custom_field.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |
4 | 5 | 6 | 16 | 17 | 18 |
Upload Image
19 |
20 |
21 | 22 | {% include "cropduster/inline.html" %} 23 | 24 |
25 |
26 |
27 | 28 |
29 | 30 | 31 |
-------------------------------------------------------------------------------- /cropduster/templates/cropduster/inline.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {{ inline_admin_formset.formset.management_form }} 4 | {{ inline_admin_formset.formset.non_form_errors }} 5 | 6 | {% for inline_admin_form in inline_admin_formset %} 7 |
8 | {% if inline_admin_formset.formset.can_delete and inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }} {{ inline_admin_form.deletion_field.label_tag }}{% endif %} 9 | {% if inline_admin_form.form.non_field_errors %}{{ inline_admin_form.form.non_field_errors }}{% endif %} 10 | {% for fieldset in inline_admin_form %} 11 | {% include "admin/includes/fieldset.html" %} 12 | {% endfor %} 13 |
14 | {% endfor %} 15 | -------------------------------------------------------------------------------- /cropduster/templates/cropduster/upload.html: -------------------------------------------------------------------------------- 1 | {% extends parent_template %} 2 | 3 | 4 | {% load i18n %} 5 | 6 | {% block extrahead %} 7 | {% if django_is_gte_19 %} 8 | 9 | {% else %} 10 | 11 | {% endif %} 12 | 13 | {{ block.super }} 14 | {{ crop_form.media }} 15 | {{ thumb_formset.media }} 16 | {% endblock %} 17 | 18 | 19 | {% block nav-global %}{% endblock %} 20 | 21 | {% block bodyclass %}{{ block.super }} cropduster-upload-form absolute-pos{% if standalone %} cropduster-standalone{% endif %}{% endblock %} 22 | 23 | {% block content-class %}content-flexible{% endblock %} 24 | 25 | {% block breadcrumbs %}{% endblock %} 26 | 27 | 28 | 29 | {% block content %} 30 | {% if not standalone %} 31 |

Upload, Crop, and Generate Thumbnails

32 | {% endif %} 33 |
34 |
35 | 36 |
37 | {% for field in upload_form %} 38 |
39 | {% if field.name != "image" %}{{ field.label_tag|safe }}{% endif %} 40 | {{ field }} 41 | {% if field.name == "image" %} 42 |
43 | {% endif %} 44 |
45 | {% endfor %} 46 | {% if standalone %} 47 | 50 | 53 | {% endif %} 54 |
55 | 56 | {% if standalone %} 57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 |
70 |
71 | 72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 | {% endif %} 80 | 81 |
82 | 83 | 86 | 87 |
88 |
89 | 90 |
91 |
92 | {{ crop_form.as_p }} 93 |
94 | {{ thumb_formset.management_form }} 95 | {% for thumb_form in thumb_formset %} 96 |
97 | {{ thumb_form.as_table }}
98 |
99 | {% endfor %} 100 |
101 |
102 |
103 |
104 | 105 | 106 |
107 |
108 |
109 |
110 |
111 |
112 | {% if not standalone %} 113 | 118 | {% endif %} 119 | 129 |
130 |
131 | 132 |
133 | {% endblock %} 134 | -------------------------------------------------------------------------------- /cropduster/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/cropduster/templatetags/__init__.py -------------------------------------------------------------------------------- /cropduster/templatetags/cropduster_tags.py: -------------------------------------------------------------------------------- 1 | import time 2 | import warnings 3 | 4 | import django 5 | from django import template 6 | from cropduster.models import Image 7 | from cropduster.resizing import Size 8 | 9 | 10 | register = template.Library() 11 | 12 | 13 | if django.VERSION >= (1, 9): 14 | tag_decorator = register.simple_tag 15 | else: 16 | tag_decorator = register.assignment_tag 17 | 18 | 19 | @tag_decorator 20 | def get_crop(image, crop_name, **kwargs): 21 | """ 22 | Get the crop of an image. Usage: 23 | 24 | {% get_crop article.image 'square_thumbnail' attribution=1 as img %} 25 | 26 | will assign to `img` a dictionary that looks like: 27 | 28 | { 29 | "url": '/media/path/to/my.jpg', 30 | "width": 150, 31 | "height" 150, 32 | "attribution": 'Stock Photoz', 33 | "attribution_link": 'http://stockphotoz.com', 34 | "caption": 'Woman laughing alone with salad.', 35 | "alt_text": 'Woman laughing alone with salad.' 36 | } 37 | 38 | For use in an image tag or style block like: 39 | 40 | 41 | 42 | The `exact_size` kwarg is deprecated. 43 | 44 | Omitting the `attribution` kwarg will omit the attribution, attribution_link, 45 | and caption. 46 | """ 47 | 48 | if "exact_size" in kwargs: 49 | warnings.warn("get_crop's `exact_size` kwarg is deprecated.", DeprecationWarning) 50 | 51 | if not image or not image.related_object: 52 | return None 53 | 54 | url = getattr(Image.get_file_for_size(image, crop_name), 'url', None) 55 | 56 | thumbs = {thumb.name: thumb for thumb in image.related_object.thumbs.all()} 57 | try: 58 | thumb = thumbs[crop_name] 59 | except KeyError: 60 | if crop_name == "original": 61 | thumb = image.related_object 62 | else: 63 | return None 64 | 65 | cache_buster = str(time.mktime(thumb.date_modified.timetuple()))[:-2] 66 | return { 67 | "url": "%s?%s" % (url, cache_buster), 68 | "width": thumb.width, 69 | "height": thumb.height, 70 | "attribution": image.related_object.attribution, 71 | "attribution_link": image.related_object.attribution_link, 72 | "caption": image.related_object.caption, 73 | "alt_text": image.related_object.alt_text, 74 | } 75 | -------------------------------------------------------------------------------- /cropduster/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | import cropduster.views 4 | import cropduster.standalone.views 5 | 6 | 7 | urlpatterns = [ 8 | re_path(r'^$', cropduster.views.index, name='cropduster-index'), 9 | re_path(r'^crop/', cropduster.views.crop, name='cropduster-crop'), 10 | re_path(r'^upload/', cropduster.views.upload, name='cropduster-upload'), 11 | re_path(r'^standalone/', cropduster.standalone.views.index, name='cropduster-standalone'), 12 | ] 13 | -------------------------------------------------------------------------------- /cropduster/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .image import ( 2 | get_image_extension, is_transparent, exif_orientation, 3 | correct_colorspace, is_animated_gif, has_animated_gif_support, process_image, 4 | smart_resize) 5 | from .paths import get_upload_foldername 6 | from .sizes import get_min_size 7 | from .thumbs import set_as_auto_crop, unset_as_auto_crop 8 | from . import jsonutils as json 9 | -------------------------------------------------------------------------------- /cropduster/utils/gifsicle.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import subprocess 4 | 5 | from cropduster.settings import CROPDUSTER_GIFSICLE_PATH 6 | from django.core.files.storage import default_storage 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class GifsicleImage(object): 13 | 14 | def __init__(self, im): 15 | if not CROPDUSTER_GIFSICLE_PATH: 16 | raise Exception( 17 | "Cannot use GifsicleImage without the gifsicle binary in the PATH") 18 | 19 | self.pil_image = im 20 | self.size = im.size 21 | self.cmd_args = [CROPDUSTER_GIFSICLE_PATH, '-O3', '-I', '-I', '-w'] 22 | self.crop_args = [] 23 | self.resize_args = [] 24 | 25 | @property 26 | def args(self): 27 | return self.cmd_args + self.crop_args + self.resize_args 28 | 29 | def crop(self, box): 30 | x1, y1, x2, y2 = box 31 | if x2 < x1: 32 | x2 = x1 33 | if y2 < y1: 34 | y2 = y1 35 | 36 | self.size = (x2 - x1, y2 - y1) 37 | self.crop_args = ['--crop', "%d,%d-%d,%d" % (x1, y1, x2, y2)] 38 | return self 39 | 40 | def resize(self, size, method): 41 | # Ignore method, PIL's algorithms don't match up 42 | self.resize_args = [ 43 | "--resize-fit", "%dx%d" % size, 44 | "--resize-method", "mix", 45 | "--resize-colors", "128", 46 | ] 47 | return self 48 | 49 | def save(self, buf, **kwargs): 50 | proc = subprocess.Popen(self.args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 51 | with default_storage.open(self.pil_image.filename, 'rb') as f: 52 | out, err = proc.communicate(input=f.read()) 53 | logger.debug(err) 54 | buf.write(out) 55 | buf.seek(0) 56 | -------------------------------------------------------------------------------- /cropduster/utils/image.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from io import BytesIO 4 | import os 5 | import warnings 6 | import math 7 | 8 | import PIL.Image 9 | from PIL import ImageFile, JpegImagePlugin 10 | 11 | from django.core.files.storage import default_storage 12 | 13 | from cropduster.settings import ( 14 | get_jpeg_quality, JPEG_SAVE_ICC_SUPPORTED, CROPDUSTER_GIFSICLE_PATH) 15 | 16 | from .gifsicle import GifsicleImage 17 | 18 | 19 | __all__ = ( 20 | 'get_image_extension', 'is_transparent', 'exif_orientation', 21 | 'correct_colorspace', 'is_animated_gif', 'has_animated_gif_support', 22 | 'process_image', 'smart_resize') 23 | 24 | 25 | # workaround for https://github.com/python-pillow/Pillow/issues/1138 26 | # without this hack, pillow misidenfifies some jpeg files as "mpo" files 27 | JpegImagePlugin._getmp = lambda x: None # noqa 28 | 29 | ImageFile.MAXBLOCK = 4096 * 4096 * 8 30 | 31 | 32 | IMAGE_EXTENSIONS = { 33 | "ARG": ".arg", "BMP": ".bmp", "BUFR": ".bufr", "CUR": ".cur", "DCX": ".dcx", 34 | "EPS": ".ps", "FITS": ".fit", "FLI": ".fli", "FPX": ".fpx", "GBR": ".gbr", 35 | "GIF": ".gif", "GRIB": ".grib", "HDF5": ".hdf", "ICNS": ".icns", "ICO": ".ico", 36 | "IM": ".im", "IPTC": ".iim", "JPEG": ".jpg", "MIC": ".mic", "MPEG": ".mpg", 37 | "MSP": ".msp", "Palm": ".palm", "PCD": ".pcd", "PCX": ".pcx", "PDF": ".pdf", 38 | "PNG": ".png", "PPM": ".ppm", "PSD": ".psd", "SGI": ".rgb", "SUN": ".ras", 39 | "TGA": ".tga", "TIFF": ".tiff", "WMF": ".wmf", "XBM": ".xbm", "XPM": ".xpm", 40 | "MPO": ".jpg", # Pillow mislabels some jpeg images as MPO files 41 | } 42 | 43 | 44 | def get_image_extension(img): 45 | if img.format in IMAGE_EXTENSIONS: 46 | return IMAGE_EXTENSIONS[img.format] 47 | else: 48 | for ext, format in PIL.Image.EXTENSION.items(): 49 | if format == img.format: 50 | return ext 51 | # Our fallback is the PIL format name in lowercase, 52 | # which is probably the file extension 53 | return ".%s" % img.format.lower() 54 | 55 | 56 | def is_transparent(image): 57 | """ 58 | Check to see if an image is transparent. 59 | """ 60 | if not isinstance(image, PIL.Image.Image): 61 | # Can only deal with PIL images, fall back to the assumption that that 62 | # it's not transparent. 63 | return False 64 | return (image.mode in ('RGBA', 'LA') or 65 | (image.mode == 'P' and 'transparency' in image.info)) 66 | 67 | 68 | def exif_orientation(im): 69 | """ 70 | Rotate and/or flip an image to respect the image's EXIF orientation data. 71 | """ 72 | try: 73 | exif = im._getexif() 74 | except (AttributeError, IndexError, KeyError, IOError): 75 | exif = None 76 | if exif: 77 | orientation = exif.get(0x0112) 78 | if orientation == 2: 79 | im = im.transpose(PIL.Image.FLIP_LEFT_RIGHT) 80 | elif orientation == 3: 81 | im = im.rotate(180) 82 | elif orientation == 4: 83 | im = im.transpose(PIL.Image.FLIP_TOP_BOTTOM) 84 | elif orientation == 5: 85 | im = im.rotate(-90).transpose(PIL.Image.FLIP_LEFT_RIGHT) 86 | elif orientation == 6: 87 | im = im.rotate(-90) 88 | elif orientation == 7: 89 | im = im.rotate(90).transpose(PIL.Image.FLIP_LEFT_RIGHT) 90 | elif orientation == 8: 91 | im = im.rotate(90) 92 | return im 93 | 94 | 95 | def correct_colorspace(im, bw=False): 96 | """ 97 | Convert images to the correct color space. 98 | 99 | bw 100 | Make the thumbnail grayscale (not really just black & white). 101 | """ 102 | if bw: 103 | if im.mode in ('L', 'LA'): 104 | return im 105 | if is_transparent(im): 106 | return im.convert('LA') 107 | else: 108 | return im.convert('L') 109 | 110 | if im.mode in ('L', 'RGB'): 111 | return im 112 | 113 | return im.convert('RGB') 114 | 115 | 116 | def is_animated_gif(im): 117 | info = getattr(im, 'info', None) or {} 118 | return bool((im.format == 'GIF' or not im.format) and info.get('extension')) 119 | 120 | 121 | def has_animated_gif_support(): 122 | return bool(CROPDUSTER_GIFSICLE_PATH) 123 | 124 | 125 | def process_image(im, save_filename=None, callback=lambda i: i, nq=0, save_params=None): 126 | is_animated = is_animated_gif(im) 127 | images = [im] 128 | 129 | if is_animated: 130 | if not CROPDUSTER_GIFSICLE_PATH: 131 | warnings.warn( 132 | "This server does not have animated gif support; your uploaded image " 133 | "has been made static.") 134 | else: 135 | images = [GifsicleImage(im)] 136 | 137 | new_images = [callback(i) for i in images] 138 | 139 | if is_animated and not save_filename: 140 | raise Exception("Animated gifs must be saved on each processing.") 141 | 142 | if save_filename: 143 | save_params = save_params or {} 144 | if im.format == 'JPEG': 145 | save_params.setdefault('quality', get_jpeg_quality(new_images[0].size[0], new_images[0].size[1])) 146 | if im.format in ('JPEG', 'PNG') and JPEG_SAVE_ICC_SUPPORTED: 147 | save_params.setdefault('icc_profile', im.info.get('icc_profile')) 148 | img = new_images[0] 149 | buf = BytesIO() 150 | img.save(buf, format=im.format, **save_params) 151 | with default_storage.open(save_filename, 'wb') as f: 152 | f.write(buf.getvalue()) 153 | with default_storage.open(save_filename, mode='rb') as f: 154 | content = f.read() 155 | pil_image = PIL.Image.open(BytesIO(content)) 156 | pil_image.filename = save_filename 157 | return pil_image 158 | 159 | return new_images[0] 160 | 161 | 162 | def smart_resize(im, final_w, final_h): 163 | """ 164 | Resizes a given image in multiple steps to ensure maximum quality and performance 165 | 166 | :param im: PIL.Image instance the image to be resized 167 | :param final_w: int the intended final width of the image 168 | :param final_h: int the intended final height of the image 169 | """ 170 | 171 | (orig_w, orig_h) = im.size 172 | if orig_w <= final_w and orig_h <= final_h: 173 | # If the image is already the right size, don't change it 174 | return im 175 | 176 | return im.resize((final_w, final_h), PIL.Image.BICUBIC) 177 | -------------------------------------------------------------------------------- /cropduster/utils/jsonutils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from cropduster.resizing import Size 4 | 5 | 6 | __all__ = ('dumps', 'loads') 7 | 8 | 9 | def json_default(obj): 10 | if callable(getattr(obj, '__serialize__', None)): 11 | dct = obj.__serialize__() 12 | module = obj.__module__ 13 | if module == '__builtin__': 14 | module = None 15 | if isinstance(obj, type): 16 | name = obj.__name__ 17 | else: 18 | name = obj.__class__.__name__ 19 | type_name = '.'.join(filter(None, [module, name])) 20 | if type_name == 'cropduster.resizing.Size': 21 | type_name = 'Size' 22 | dct.update({'__type__': type_name}) 23 | return dct 24 | raise TypeError("object of type %s is not JSON serializable" % type(obj).__name__) 25 | 26 | 27 | def object_hook(dct): 28 | if dct.get('__type__') in ['Size', 'cropduster.resizing.Size']: 29 | return Size( 30 | name=dct.get('name'), 31 | label=dct.get('label'), 32 | w=dct.get('w'), 33 | h=dct.get('h'), 34 | min_w=dct.get('min_w'), 35 | min_h=dct.get('min_h'), 36 | max_w=dct.get('max_w'), 37 | max_h=dct.get('max_h'), 38 | retina=dct.get('retina'), 39 | auto=dct.get('auto'), 40 | required=dct.get('required')) 41 | return dct 42 | 43 | 44 | def dumps(obj, *args, **kwargs): 45 | kwargs.setdefault('default', json_default) 46 | return json.dumps(obj, *args, **kwargs) 47 | 48 | 49 | def loads(s, *args, **kwargs): 50 | if isinstance(s, bytes): 51 | s = s.decode('utf-8') 52 | kwargs.setdefault('object_hook', object_hook) 53 | return json.loads(s, *args, **kwargs) 54 | -------------------------------------------------------------------------------- /cropduster/utils/paths.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | from django.core.files.storage import default_storage 5 | from django.conf import settings 6 | from django.db.models.fields.files import FileField 7 | 8 | 9 | 10 | __all__ = ('get_upload_foldername') 11 | 12 | 13 | def get_upload_foldername(file_name, upload_to='%Y/%m'): 14 | # Generate date based path to put uploaded file. 15 | file_field = FileField(upload_to=upload_to) 16 | if not file_name: 17 | file_name = 'no_name' 18 | filename = file_field.generate_filename(None, file_name) 19 | filename = re.sub(r'[_\-]+', '_', filename) 20 | 21 | root_dir = os.path.splitext(filename)[0] 22 | parent_dir, _, basename = root_dir.rpartition('/') 23 | image_dir = '' 24 | i = 1 25 | dir_name = basename 26 | while not image_dir: 27 | try: 28 | sub_dirs, _ = default_storage.listdir(parent_dir) 29 | while dir_name in sub_dirs: 30 | dir_name = "%s-%d" % (basename, i) 31 | i += 1 32 | except OSError: 33 | os.makedirs(os.path.join(settings.MEDIA_ROOT, parent_dir)) 34 | else: 35 | image_dir = os.path.join(parent_dir, dir_name) 36 | 37 | return image_dir 38 | -------------------------------------------------------------------------------- /cropduster/utils/sizes.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from . import jsonutils as json 4 | from ..resizing import Size 5 | 6 | 7 | __all__ = ('get_min_size',) 8 | 9 | 10 | def get_min_size(sizes): 11 | """Determine the minimum required width & height from a list of sizes.""" 12 | min_w, min_h = 0, 0 13 | if sizes == 'null': 14 | return (0, 0) 15 | if isinstance(sizes, str): 16 | sizes = json.loads(sizes) 17 | if not sizes: 18 | return (0, 0) 19 | # The min width and height for the image = the largest w / h of the sizes 20 | for size in Size.flatten(sizes): 21 | if size.required: 22 | min_w = max(size.min_w, min_w) 23 | min_h = max(size.min_h, min_h) 24 | return (min_w, min_h) 25 | -------------------------------------------------------------------------------- /cropduster/utils/thumbs.py: -------------------------------------------------------------------------------- 1 | from ..resizing import Crop 2 | from ..exceptions import CropDusterException 3 | 4 | 5 | class DummyImage(object): 6 | def __init__(self, image): 7 | self.size = [image.width, image.height] 8 | 9 | 10 | def set_as_auto_crop(thumb, reference_thumb, force=False): 11 | """ 12 | Sometimes you need to move crop sizes into different crop groups. This 13 | utility takes a Thumb and a new reference thumb that will become its 14 | parent. 15 | 16 | This function can be destructive so, by default, it does not re-set the 17 | parent crop if the new crop box is different than the old crop box. 18 | """ 19 | dummy_image = DummyImage(thumb.image) 20 | current_best_fit = Crop(thumb.get_crop_box(), dummy_image).best_fit(thumb.width, thumb.height) 21 | new_best_fit = Crop(reference_thumb.get_crop_box(), dummy_image).best_fit(thumb.width, thumb.height) 22 | 23 | if current_best_fit.box != new_best_fit.box and not force: 24 | raise CropDusterException("Current image crop based on '%s' is " 25 | "different than new image crop based on '%s'." % (thumb.reference_thumb.name, reference_thumb.name)) 26 | 27 | if reference_thumb.reference_thumb: 28 | raise CropDusterException("Reference thumbs cannot have reference thumbs.") 29 | 30 | if not thumb.reference_thumb: 31 | thumb.crop_w = None 32 | thumb.crop_h = None 33 | thumb.crop_x = None 34 | thumb.crop_y = None 35 | thumb.reference_thumb = reference_thumb 36 | thumb.save() 37 | 38 | 39 | def unset_as_auto_crop(thumb): 40 | """ 41 | Crop information is normalized on the parent Thumb row so auto-crops do not 42 | have crop height/width/x/y associated with them. 43 | 44 | This function takes a Thumb as an argument, generates the best_fit from 45 | its reference_thumb, sets the relevant geometry, and clears the original 46 | reference_thumb foreign key. 47 | """ 48 | if not thumb.reference_thumb: 49 | return 50 | 51 | reference_thumb_box = thumb.reference_thumb.get_crop_box() 52 | crop = Crop(reference_thumb_box, DummyImage(thumb.image)) 53 | best_fit = crop.best_fit(thumb.width, thumb.height) 54 | 55 | thumb.reference_thumb = None 56 | thumb.crop_w = best_fit.box.w 57 | thumb.crop_h = best_fit.box.h 58 | thumb.crop_x = best_fit.box.x1 59 | thumb.crop_y = best_fit.box.y1 60 | thumb.save() 61 | -------------------------------------------------------------------------------- /cropduster/views/base.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | from logging import getLogger 4 | 5 | from django import http 6 | from django.utils.decorators import classonlymethod 7 | 8 | 9 | logger = getLogger('django.request') 10 | 11 | 12 | class View(object): 13 | """ 14 | Intentionally simple parent class for all views. Only implements 15 | dispatch-by-method and simple sanity checking. 16 | """ 17 | 18 | http_method_names = ['get', 'post', 'put', 'delete', 'head', 'options', 'trace'] 19 | 20 | def __init__(self, **kwargs): 21 | """ 22 | Constructor. Called in the URLconf; can contain helpful extra 23 | keyword arguments, and other things. 24 | """ 25 | # Go through keyword arguments, and either save their values to our 26 | # instance, or raise an error. 27 | for key, value in kwargs.items(): 28 | setattr(self, key, value) 29 | 30 | @classonlymethod 31 | def as_view(cls, **initkwargs): 32 | """ 33 | Main entry point for a request-response process. 34 | """ 35 | # sanitize keyword arguments 36 | for key in initkwargs: 37 | if key in cls.http_method_names: 38 | raise TypeError("You tried to pass in the %s method name as a " 39 | "keyword argument to %s(). Don't do that." 40 | % (key, cls.__name__)) 41 | if not hasattr(cls, key): 42 | raise TypeError("%s() received an invalid keyword %r" % ( 43 | cls.__name__, key)) 44 | 45 | def view(request, *args, **kwargs): 46 | self = cls(**initkwargs) 47 | if hasattr(self, 'get') and not hasattr(self, 'head'): 48 | self.head = self.get 49 | return self.dispatch(request, *args, **kwargs) 50 | 51 | # take name and docstring from class 52 | update_wrapper(view, cls, updated=()) 53 | 54 | # and possible attributes set by decorators 55 | # like csrf_exempt from dispatch 56 | update_wrapper(view, cls.dispatch, assigned=()) 57 | return view 58 | 59 | def dispatch(self, request, *args, **kwargs): 60 | # Try to dispatch to the right method; if a method doesn't exist, 61 | # defer to the error handler. Also defer to the error handler if the 62 | # request method isn't on the approved list. 63 | if request.method.lower() in self.http_method_names: 64 | handler = getattr(self, request.method.lower(), self.http_method_not_allowed) 65 | else: 66 | handler = self.http_method_not_allowed 67 | self.request = request 68 | self.args = args 69 | self.kwargs = kwargs 70 | return handler(request, *args, **kwargs) 71 | 72 | def http_method_not_allowed(self, request, *args, **kwargs): 73 | allowed_methods = [m for m in self.http_method_names if hasattr(self, m)] 74 | logger.warning('Method Not Allowed (%s): %s', request.method, request.path, 75 | extra={ 76 | 'status_code': 405, 77 | 'request': self.request 78 | } 79 | ) 80 | return http.HttpResponseNotAllowed(allowed_methods) 81 | -------------------------------------------------------------------------------- /cropduster/views/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import os 4 | import hashlib 5 | 6 | import PIL.Image 7 | 8 | from django import forms 9 | from django.core.exceptions import ObjectDoesNotExist 10 | from django.core.files.storage import default_storage 11 | from django.conf import settings 12 | from django.forms.forms import NON_FIELD_ERRORS 13 | from django.forms.models import BaseModelFormSet 14 | from django.forms.utils import ErrorDict as _ErrorDict 15 | from django.utils.encoding import force_str 16 | from django.utils.html import conditional_escape 17 | from django.utils.safestring import mark_safe 18 | 19 | from cropduster.models import Thumb 20 | from cropduster.utils import (json, get_upload_foldername, get_min_size, 21 | get_image_extension) 22 | 23 | 24 | class ErrorDict(_ErrorDict): 25 | 26 | def as_ul(self): 27 | if not self: return '' 28 | error_list = [] 29 | for k, v in self.items(): 30 | if k == NON_FIELD_ERRORS: 31 | k = '' 32 | error_list.append('%s%s' % (k, conditional_escape(force_str(v)))) 33 | 34 | return mark_safe('
    %s
' 35 | % ''.join(['
  • %s
  • ' % e for e in error_list])) 36 | 37 | 38 | def clean_upload_data(data): 39 | image = data['image'] 40 | image.seek(0) 41 | try: 42 | pil_image = PIL.Image.open(image) 43 | except IOError as e: 44 | if e.errno: 45 | error_msg = force_str(e) 46 | else: 47 | error_msg = "Invalid or unsupported image file" 48 | raise forms.ValidationError({"image": [error_msg]}) 49 | else: 50 | extension = get_image_extension(pil_image) 51 | 52 | upload_to = data['upload_to'] or None 53 | 54 | folder_path = get_upload_foldername(image.name, upload_to=upload_to) 55 | 56 | (w, h) = (orig_w, orig_h) = pil_image.size 57 | sizes = data.get('sizes') 58 | if sizes: 59 | (min_w, min_h) = get_min_size(sizes) 60 | 61 | if (orig_w < min_w or orig_h < min_h): 62 | raise forms.ValidationError({"image": [( 63 | "Image must be at least %(min_w)sx%(min_h)s " 64 | "(%(min_w)s pixels wide and %(min_h)s pixels high). " 65 | "The image you uploaded was %(orig_w)sx%(orig_h)s pixels.") % { 66 | "min_w": min_w, 67 | "min_h": min_h, 68 | "orig_w": orig_w, 69 | "orig_h": orig_h 70 | }]}) 71 | 72 | if w <= 0: 73 | raise forms.ValidationError({"image": ["Invalid image: width is %d" % w]}) 74 | elif h <= 0: 75 | raise forms.ValidationError({"image": ["Invalid image: height is %d" % h]}) 76 | 77 | # File is good, get rid of the tmp file 78 | orig_file_path = os.path.join(folder_path, 'original' + extension) 79 | image.seek(0) 80 | md5_hash = hashlib.md5() 81 | default_storage.save(orig_file_path, image) 82 | with default_storage.open(orig_file_path) as f: 83 | md5_hash.update(f.read()) 84 | f.seek(0) 85 | data['image'] = f 86 | data['md5'] = md5_hash.hexdigest() 87 | 88 | return data 89 | 90 | 91 | class FormattedErrorMixin(object): 92 | 93 | def full_clean(self): 94 | super(FormattedErrorMixin, self).full_clean() 95 | if self._errors: 96 | self._errors = ErrorDict(self._errors) 97 | 98 | def _clean_form(self): 99 | try: 100 | self.cleaned_data = self.clean() 101 | except forms.ValidationError as e: 102 | self._errors = e.update_error_dict(self._errors) 103 | # Wrap newly updated self._errors values in self.error_class 104 | # (defaults to django.forms.util.ErrorList) 105 | for k, v in self._errors.items(): 106 | if isinstance(v, list) and not isinstance(v, self.error_class): 107 | self._errors[k] = self.error_class(v) 108 | if not isinstance(self._errors, _ErrorDict): 109 | self._errors = ErrorDict(self._errors) 110 | 111 | 112 | class UploadForm(FormattedErrorMixin, forms.Form): 113 | 114 | image = forms.FileField(required=True) 115 | md5 = forms.CharField(required=False) 116 | sizes = forms.CharField(required=False) 117 | image_element_id = forms.CharField(required=False) 118 | standalone = forms.BooleanField(required=False) 119 | upload_to = forms.CharField(required=False) 120 | 121 | # The width and height of the image to be generated for 122 | # crop preview after upload 123 | preview_width = forms.IntegerField(required=False) 124 | preview_height = forms.IntegerField(required=False) 125 | 126 | def clean(self): 127 | data = super(UploadForm, self).clean() 128 | return clean_upload_data(data) 129 | 130 | def clean_sizes(self): 131 | sizes = self.cleaned_data.get('sizes') 132 | try: 133 | return json.loads(sizes) 134 | except: 135 | return [] 136 | 137 | 138 | class CropForm(forms.Form): 139 | 140 | class Media: 141 | css = {'all': ( 142 | "cropduster/css/cropduster.css", 143 | "cropduster/css/jquery.jcrop.css", 144 | "cropduster/css/upload.css", 145 | )} 146 | js = ( 147 | "cropduster/js/json2.js", 148 | "cropduster/js/jquery.class.js", 149 | "cropduster/js/jquery.form.js", 150 | "cropduster/js/jquery.jcrop.js", 151 | "cropduster/js/cropduster.js", 152 | "cropduster/js/upload.js", 153 | ) 154 | 155 | image_id = forms.IntegerField(required=False) 156 | orig_image = forms.CharField(max_length=512, required=False) 157 | orig_w = forms.IntegerField(required=False) 158 | orig_h = forms.IntegerField(required=False) 159 | sizes = forms.CharField() 160 | thumbs = forms.CharField(required=False) 161 | standalone = forms.BooleanField(required=False) 162 | 163 | def clean_sizes(self): 164 | try: 165 | json.loads(self.cleaned_data.get('sizes', '[]')) 166 | except: 167 | return [] 168 | 169 | def clean_thumbs(self): 170 | try: 171 | return json.loads(self.cleaned_data.get('thumbs', '{}')) 172 | except: 173 | return {} 174 | 175 | 176 | class ThumbForm(forms.ModelForm): 177 | 178 | id = forms.IntegerField(required=False, widget=forms.HiddenInput) 179 | thumbs = forms.CharField(required=False) 180 | size = forms.CharField(required=False) 181 | changed = forms.BooleanField(required=False) 182 | 183 | class Meta: 184 | model = Thumb 185 | fields = ( 186 | 'id', 'name', 'width', 'height', 187 | 'crop_x', 'crop_y', 'crop_w', 'crop_h', 'thumbs', 'size', 'changed') 188 | 189 | def clean_size(self): 190 | try: 191 | return json.loads(self.cleaned_data.get('size', 'null')) 192 | except: 193 | return None 194 | 195 | def clean_thumbs(self): 196 | try: 197 | return json.loads(self.cleaned_data.get('thumbs', '{}')) 198 | except: 199 | return {} 200 | 201 | 202 | class ThumbFormSet(BaseModelFormSet): 203 | """ 204 | If the form submitted empty strings for thumb pks, change to None before 205 | calling AutoField.get_prep_value() (so that int('') doesn't throw a 206 | ValueError). 207 | """ 208 | 209 | def _existing_object(self, pk): 210 | """ 211 | Avoid potentially expensive list comprehension over self.queryset() 212 | in the parent method. 213 | """ 214 | if not hasattr(self, '_object_dict'): 215 | self._object_dict = {} 216 | if not pk: 217 | return None 218 | try: 219 | obj = self.get_queryset().get(pk=pk) 220 | except ObjectDoesNotExist: 221 | return None 222 | else: 223 | self._object_dict[obj.pk] = obj 224 | return super(ThumbFormSet, self)._existing_object(pk) 225 | 226 | def _construct_form(self, i, **kwargs): 227 | if self.is_bound and i < self.initial_form_count(): 228 | mutable = getattr(self.data, '_mutable', False) 229 | self.data._mutable = True 230 | pk_key = "%s-%s" % (self.add_prefix(i), self.model._meta.pk.name) 231 | self.data[pk_key] = self.data.get(pk_key) or None 232 | self.data._mutable = mutable 233 | form = super(ThumbFormSet, self)._construct_form(i, **kwargs) 234 | if self.data.get('crop-standalone') == 'on': 235 | form.fields[self.model._meta.pk.name].required = False 236 | return form 237 | -------------------------------------------------------------------------------- /cropduster/views/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_admin_base_template(): 5 | if 'custom_admin' in settings.INSTALLED_APPS: 6 | return 'custom_admin/base.html' 7 | elif 'django_admin_mod' in settings.INSTALLED_APPS: 8 | return 'admin_mod/base.html' 9 | else: 10 | return 'admin/base.html' 11 | 12 | 13 | class FakeQuerySet(object): 14 | 15 | def __init__(self, objs, queryset): 16 | self.objs = objs 17 | self.queryset = queryset 18 | 19 | def __iter__(self): 20 | obj_iter = iter(self.objs) 21 | while True: 22 | try: 23 | yield obj_iter.next() 24 | except StopIteration: 25 | break 26 | 27 | def __len__(self): 28 | return len(self.objs) 29 | 30 | @property 31 | def ordered(self): 32 | return True 33 | 34 | @property 35 | def db(self): 36 | return self.queryset.db 37 | 38 | def __getitem__(self, index): 39 | return self.objs[index] 40 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-cropduster.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-cropduster.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-cropduster" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-cropduster" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 1 2 | 3 | .. _changelog: 4 | 5 | .. include:: ../CHANGELOG.rst 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-cropduster documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 5 22:25:41 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.mathjax', 37 | "sphinx.ext.intersphinx", 38 | "sphinx.ext.viewcode", 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The encoding of source files. 50 | #source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = 'index' 54 | 55 | # General information about the project. 56 | project = 'django-cropduster' 57 | copyright = '2015, The Atlantic' 58 | author = 'The Atlantic' 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = '4.8' 66 | # The full version, including alpha/beta/rc tags. 67 | release = '4.8.18' 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # There are two options for replacing |today|: either, you set today to some 77 | # non-false value, then it is used: 78 | #today = '' 79 | # Else, today_fmt is used as the format for a strftime call. 80 | #today_fmt = '%B %d, %Y' 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | exclude_patterns = ['_build'] 85 | 86 | # The reST default role (used for this markup: `text`) to use for all 87 | # documents. 88 | #default_role = None 89 | 90 | # If true, '()' will be appended to :func: etc. cross-reference text. 91 | #add_function_parentheses = True 92 | 93 | # If true, the current module name will be prepended to all description 94 | # unit titles (such as .. function::). 95 | #add_module_names = True 96 | 97 | # If true, sectionauthor and moduleauthor directives will be shown in the 98 | # output. They are ignored by default. 99 | #show_authors = False 100 | 101 | # The name of the Pygments (syntax highlighting) style to use. 102 | pygments_style = 'trac' 103 | 104 | # A list of ignored prefixes for module index sorting. 105 | #modindex_common_prefix = [] 106 | 107 | # If true, keep warnings as "system message" paragraphs in the built documents. 108 | #keep_warnings = False 109 | 110 | # If true, `todo` and `todoList` produce output, else they produce nothing. 111 | todo_include_todos = False 112 | 113 | 114 | # -- Options for HTML output ---------------------------------------------- 115 | 116 | # The theme to use for HTML and HTML Help pages. See the documentation for 117 | # a list of builtin themes. 118 | html_theme = 'default' 119 | 120 | # Theme options are theme-specific and customize the look and feel of a theme 121 | # further. For a list of options available for each theme, see the 122 | # documentation. 123 | #html_theme_options = {} 124 | 125 | # Add any paths that contain custom themes here, relative to this directory. 126 | #html_theme_path = [] 127 | 128 | # The name for this set of Sphinx documents. If None, it defaults to 129 | # " v documentation". 130 | #html_title = None 131 | 132 | # A shorter title for the navigation bar. Default is the same as html_title. 133 | #html_short_title = None 134 | 135 | # The name of an image file (relative to this directory) to place at the top 136 | # of the sidebar. 137 | #html_logo = None 138 | 139 | # The name of an image file (within the static path) to use as favicon of the 140 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 141 | # pixels large. 142 | #html_favicon = None 143 | 144 | # Add any paths that contain custom static files (such as style sheets) here, 145 | # relative to this directory. They are copied after the builtin static files, 146 | # so a file named "default.css" will overwrite the builtin "default.css". 147 | html_static_path = ['_static'] 148 | 149 | # Add any extra paths that contain custom files (such as robots.txt or 150 | # .htaccess) here, relative to this directory. These files are copied 151 | # directly to the root of the documentation. 152 | #html_extra_path = [] 153 | 154 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 155 | # using the given strftime format. 156 | #html_last_updated_fmt = '%b %d, %Y' 157 | 158 | # If true, SmartyPants will be used to convert quotes and dashes to 159 | # typographically correct entities. 160 | #html_use_smartypants = True 161 | 162 | # Custom sidebar templates, maps document names to template names. 163 | #html_sidebars = {} 164 | 165 | # Additional templates that should be rendered to pages, maps page names to 166 | # template names. 167 | #html_additional_pages = {} 168 | 169 | # If false, no module index is generated. 170 | #html_domain_indices = True 171 | 172 | # If false, no index is generated. 173 | #html_use_index = True 174 | 175 | # If true, the index is split into individual pages for each letter. 176 | #html_split_index = False 177 | 178 | # If true, links to the reST sources are added to the pages. 179 | #html_show_sourcelink = True 180 | 181 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 182 | #html_show_sphinx = True 183 | 184 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 185 | #html_show_copyright = True 186 | 187 | # If true, an OpenSearch description file will be output, and all pages will 188 | # contain a tag referring to it. The value of this option must be the 189 | # base URL from which the finished HTML is served. 190 | #html_use_opensearch = '' 191 | 192 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 193 | #html_file_suffix = None 194 | 195 | # Language to be used for generating the HTML full-text search index. 196 | # Sphinx supports the following languages: 197 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 198 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 199 | #html_search_language = 'en' 200 | 201 | # A dictionary with options for the search language support, empty by default. 202 | # Now only 'ja' uses this config value 203 | #html_search_options = {'type': 'default'} 204 | 205 | # The name of a javascript file (relative to the configuration directory) that 206 | # implements a search results scorer. If empty, the default will be used. 207 | #html_search_scorer = 'scorer.js' 208 | 209 | # Output file base name for HTML help builder. 210 | htmlhelp_basename = 'django-cropdusterdoc' 211 | 212 | # -- Options for LaTeX output --------------------------------------------- 213 | 214 | latex_elements = { 215 | # The paper size ('letterpaper' or 'a4paper'). 216 | #'papersize': 'letterpaper', 217 | 218 | # The font size ('10pt', '11pt' or '12pt'). 219 | #'pointsize': '10pt', 220 | 221 | # Additional stuff for the LaTeX preamble. 222 | #'preamble': '', 223 | 224 | # Latex figure (float) alignment 225 | #'figure_align': 'htbp', 226 | } 227 | 228 | # Grouping the document tree into LaTeX files. List of tuples 229 | # (source start file, target name, title, 230 | # author, documentclass [howto, manual, or own class]). 231 | latex_documents = [ 232 | (master_doc, 'django-cropduster.tex', 'django-cropduster Documentation', 233 | 'The Atlantic', 'manual'), 234 | ] 235 | 236 | # The name of an image file (relative to this directory) to place at the top of 237 | # the title page. 238 | #latex_logo = None 239 | 240 | # For "manual" documents, if this is true, then toplevel headings are parts, 241 | # not chapters. 242 | #latex_use_parts = False 243 | 244 | # If true, show page references after internal links. 245 | #latex_show_pagerefs = False 246 | 247 | # If true, show URL addresses after external links. 248 | #latex_show_urls = False 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #latex_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #latex_domain_indices = True 255 | 256 | 257 | # -- Options for manual page output --------------------------------------- 258 | 259 | # One entry per manual page. List of tuples 260 | # (source start file, name, description, authors, manual section). 261 | man_pages = [ 262 | (master_doc, 'django-cropduster', 'django-cropduster Documentation', 263 | [author], 1) 264 | ] 265 | 266 | # If true, show URL addresses after external links. 267 | #man_show_urls = False 268 | 269 | 270 | # -- Options for Texinfo output ------------------------------------------- 271 | 272 | # Grouping the document tree into Texinfo files. List of tuples 273 | # (source start file, target name, title, author, 274 | # dir menu entry, description, category) 275 | texinfo_documents = [ 276 | (master_doc, 'django-cropduster', 'django-cropduster Documentation', 277 | author, 'django-cropduster', 'One line description of project.', 278 | 'Miscellaneous'), 279 | ] 280 | 281 | # Documents to append as an appendix to all manuals. 282 | #texinfo_appendices = [] 283 | 284 | # If false, no module index is generated. 285 | #texinfo_domain_indices = True 286 | 287 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 288 | #texinfo_show_urls = 'footnote' 289 | 290 | # If true, do not generate a @detailmenu in the "Top" node's menu. 291 | #texinfo_no_detailmenu = False 292 | 293 | if not on_rtd: # only import and set the theme if we're building docs locally 294 | import sphinx_rtd_theme 295 | html_theme = 'sphinx_rtd_theme' 296 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 297 | -------------------------------------------------------------------------------- /docs/customization.rst: -------------------------------------------------------------------------------- 1 | .. _customization 2 | 3 | Customization 4 | ============= 5 | 6 | Available Settings 7 | ------------------ 8 | 9 | ``CROPDUSTER_JPEG_QUALITY`` 10 | The value of the ``quality`` keyword argument passed to Pillow's ``save()`` method for JPEG files. Can be either a numeric value or a callable which gets the image's width and height as arguments and should return a numeric value. 11 | 12 | ``CROPDUSTER_PREVIEW_WIDTH``, ``CROPDUSTER_PREVIEW_HEIGHT`` 13 | The maximum width and height, respectively, of the preview image shown in the cropduster upload dialog. 14 | 15 | ``CROPDUSTER_GIFSICLE_PATH`` 16 | The full path to gifsicle binary. If this setting is not defined it will search for it in the ``PATH``. 17 | -------------------------------------------------------------------------------- /docs/how_it_works.rst: -------------------------------------------------------------------------------- 1 | .. _how_it_works 2 | 3 | How it Works 4 | ============ 5 | 6 | GenericForeignFileField 7 | ----------------------- 8 | 9 | Nearly all of the functionality in cropduster comes from its django model field, :py:class:`CropDusterField `. A great deal of functionality, in turn, comes from the :py:class:`GenericForeignFileField ` in the package `django-generic-plus`_. Put in simplest terms, django-generic-plus allows one to create django model fields that are a hybrid of a `FileField`_ and a reverse generic foreign key (similar to Django's `GenericRelation`_, except that the relationship is one-to-one rather than one-to-many). In some respects these fields act the same as a `FileField`_ (or, in the case of django-cropduster, an `ImageField`_), and when they are accessed from a model they have the same API as a `FieldFile`_. But, as part of their hybrid status, ``GenericForeignFileField`` fields also have functionality that allows relating a file to one or more fields in another model. In the case of django-cropduster, this model is :py:class:`cropduster.models.Image`. An example might be edifying. Let's begin with a simple model: 10 | 11 | .. code-block:: python 12 | 13 | class Author(models.Model): 14 | name = models.CharField(max_length=255) 15 | headshot = CropDusterField(upload_to='img/authors', sizes=[Size("main")]) 16 | 17 | Assuming that we are dealing with an ``Author`` created in the Django admin, one would access the :py:class:`cropduster.Image ` instance using ``Author.headshot.related_object``: 18 | 19 | .. code-block:: python 20 | 21 | >>> author = Author.objects.get(pk=1) 22 | >>> author.headshot 23 | 24 | >>> author.headshot.path 25 | "/www/project/media/img/authors/mark-twain/original.jpg" 26 | >>> author.headshot.related_object 27 | 28 | 29 | The accessor at ``author.headshot.related_object`` is basically equivalent to running the following python code: 30 | 31 | .. code-block:: python 32 | 33 | try: 34 | Image.objects.get( 35 | content_type=ContentType.objects.get_for_model(author), 36 | object_id=author.pk, 37 | field_identifier='') 38 | except Image.DoesNotExist: 39 | return None 40 | 41 | Creating an instance with a cropduster field outside of the Django admin requires the creation of an instance of :py:class:`cropduster.Image ` and a call to the ``generate_thumbs`` method: 42 | 43 | .. code-block:: python 44 | 45 | from cropduster.models import Image 46 | 47 | author = Author.objects.create( 48 | name="Mark Twain", 49 | headshot="img/authors/mark-twain/original.jpg") 50 | author.save() 51 | 52 | image = Image.objects.create( 53 | content_object=author, 54 | field_identifier='', 55 | image=author.headshot.name) 56 | 57 | author.headshot.generate_thumbs() 58 | 59 | .. note:: 60 | 61 | Cropduster requires that images follow a certain path structure. Let's continue with the example above. Using the built-in Django `ImageField`_, uploading the file ``mark-twain.jpg`` would place it in ``img/authors/mark-twain.jpg`` (relative to the ``MEDIA_ROOT``). Because cropduster needs a place to put its thumbnails, it puts all images in a directory and saves the original image to ``original.%(ext)s`` in that folder. So the cropduster-compatible path for ``img/authors/mark-twain.jpg`` would be ``img/authors/mark-twain/original.jpg``. When a file is uploaded via the Django admin this file structure is created seamlessly, but it must be kept in mind when importing an image into cropduster from outside of the admin. 62 | 63 | .. _FileField: https://docs.djangoproject.com/en/1.8/ref/models/fields/#filefield 64 | .. _ImageField: https://docs.djangoproject.com/en/1.8/ref/models/fields/#django.db.models.ImageField 65 | .. _GenericRelation: https://docs.djangoproject.com/en/1.8/ref/contrib/contenttypes/#django.contrib.contenttypes.fields.GenericRelation 66 | .. _django-generic-plus: https://github.com/theatlantic/django-generic-plus 67 | .. _FieldFile: https://docs.djangoproject.com/en/1.8/ref/models/fields/#django.db.models.fields.files.FieldFile -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-cropduster documentation master file, created by 2 | sphinx-quickstart on Fri Jun 5 22:25:41 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Documentation 7 | ============= 8 | 9 | django-cropduster is a project that makes a form field available that uses the `Jcrop jQuery plugin `_. It is a drop-in replacement for Django's ``ImageField`` and allows users to generate multiple crops from images, using predefined sizes and aspect ratios. django-cropduster was created by developers at `The Atlantic `_. 10 | 11 | Compatibility 12 | ============= 13 | 14 | django-cropduster is compatible with python 2.7 and 3.4, and Django versions 1.4 - 1.8. 15 | 16 | Contents 17 | -------- 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | quickstart 23 | customization 24 | how_it_works 25 | changelog 26 | 27 | License 28 | ------- 29 | 30 | The django code is licensed under the `Simplified BSD License `_. View the ``LICENSE`` file under the root directory for complete license and copyright information. 31 | 32 | The Jcrop jQuery library included is used under the `MIT License `_. 33 | 34 | Indices and tables 35 | ------------------ 36 | 37 | * :ref:`genindex` 38 | * :ref:`modindex` 39 | * :ref:`search` 40 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. |cropduster| replace:: django-cropduster 2 | .. |version| replace:: 4.8.18 3 | 4 | .. _quickstart: 5 | 6 | Quick start guide 7 | ================= 8 | 9 | `Django `_ version 1.4–1.8 needs to be installed to use django-cropduster. Installing cropduster should install its dependencies, `django-generic-plus `_, `Pillow `_, and `python-xmp-toolkit `_. 10 | 11 | Installation 12 | ------------ 13 | 14 | .. code-block:: bash 15 | 16 | pip install django-cropduster 17 | 18 | Go to https://github.com/theatlantic/django-cropduster if you need to download a package or clone/fork the repository. 19 | 20 | Setup 21 | ----- 22 | 23 | Open ``settings.py`` and add ``cropduster`` to your ``INSTALLED_APPS`` 24 | 25 | .. code-block:: python 26 | 27 | INSTALLED_APPS = ( 28 | # ... 29 | 'cropduster', 30 | ) 31 | 32 | Add URL-patterns: 33 | 34 | .. code-block:: python 35 | 36 | urlpatterns = patterns('', 37 | # ... 38 | url(r'^cropduster/', include('cropduster.urls')), 39 | ) 40 | 41 | Collect the static files: 42 | 43 | .. code-block:: bash 44 | 45 | $ python manage.py collectstatic 46 | 47 | Example Usage 48 | ------------- 49 | 50 | Model field 51 | ........... 52 | 53 | ``CropDusterField`` takes the same arguments as Django's ``ImageField``, as well as the additional keyword argument ``sizes``. The ``sizes`` should either be a list of ``cropduster.models.Size`` objects, or a callable that returns a list of ``Size`` objects. 54 | 55 | .. code-block:: python 56 | 57 | from cropduster.models import CropDusterField, Size 58 | 59 | class ExampleModel(models.Model): 60 | 61 | image = CropDusterField(upload_to="some/path", sizes=[ 62 | Size("main", w=1024, h=768, label="Main", auto=[ 63 | Size("square", w=1000, h=1000), 64 | Size("main@2x", w=2048, h=1536, required=False), 65 | ]), 66 | Size("thumb", w=400, label="Thumbnail"), 67 | Size("freeform", label="Free-form")]) 68 | 69 | second_image = CropDusterField(upload_to="some/path", 70 | field_identifier="second", 71 | sizes=[Size("100x100", w=100, h=100)]) 72 | 73 | Given the above model, the user will be prompted to make three crops after uploading an image for field ``image``: The first "main" crop would result in a 1024x768 image. It would also generate a 1000x1000 square image (which will be an optimal recropping based on the crop box the user created at the 4/3 aspect ratio) and, optionally, a "retina" crop ("main@2x") if the source image and user crop are large enough. The second "thumbnail" cropped image would have a width of 400 pixels and a variable height. The third "freeform" crop would permit the user to select any size crop whatsoever. 74 | 75 | The field ``second_image`` passes the keyword argument ``field_identifier`` to ``CropDusterField``. If there is only one ``CropDusterField`` on a given model then the ``field_identifier`` argument is unnecessary (it defaults to ``""``). But if there is more than one ``CropDusterField``, ``field_identifier`` is a required field for the second, third, etc. fields. This is because it allows for a unique generic foreign key lookup to the cropduster image database table. 76 | 77 | Admin Integration 78 | ................. 79 | 80 | Adding the cropduster widget to the django admin requires no extra work. Simply ensure that the field is included in the ``ModelAdmin`` class. 81 | 82 | Template usage 83 | .............. 84 | 85 | To get a dictionary containing information about an image within a template, use the ``get_crop`` templatetag: 86 | 87 | .. code-block:: django 88 | 89 | {% load cropduster_tags %} 90 | 91 | {% get_crop obj.image 'large' as img %} 92 | 93 | {% if img %} 94 |
    95 | {{ alt_text }} 97 | {% if img.attribution %} 98 |
    99 | {{ img.caption }} (credit: {{ img.attribution }}) 100 |
    101 | {% endif %} 102 |
    103 | {% endif %} 104 | 105 | Testing 106 | ------- 107 | 108 | To run the unit tests: 109 | 110 | .. code-block:: bash 111 | 112 | DJANGO_SELENIUM_TESTS=1 python manage.py test cropduster 113 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | addopts = --tb=short --create-db --cov=cropduster 4 | django_find_project = false 5 | python_files = tests.py test_*.py *_tests.py 6 | testpaths = tests 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | 5 | 6 | setup( 7 | name='django-cropduster', 8 | version=__import__('cropduster').__version__, 9 | author='The Atlantic', 10 | author_email='programmers@theatlantic.com', 11 | url='https://github.com/theatlantic/django-cropduster', 12 | description='Django image uploader and cropping tool', 13 | packages=find_packages(exclude=["tests"]), 14 | zip_safe=False, 15 | long_description=open('README.rst').read(), 16 | license='BSD', 17 | platforms='any', 18 | install_requires=[ 19 | 'Pillow', 20 | 'python-xmp-toolkit', 21 | 'django-generic-plus>=2.0.3', 22 | ], 23 | include_package_data=True, 24 | python_requires='>=3', 25 | classifiers=[ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'Environment :: Web Environment', 28 | 'Intended Audience :: Developers', 29 | 'Natural Language :: English', 30 | 'Operating System :: OS Independent', 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "Framework :: Django", 40 | "Framework :: Django :: 2.2", 41 | "Framework :: Django :: 3.2", 42 | "Framework :: Django :: 4.0", 43 | "Framework :: Django :: 4.1", 44 | "Framework :: Django :: 4.2", 45 | ]) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Author, Article, OptionalSizes, OrphanedThumbs 3 | 4 | 5 | admin.site.register(Author) 6 | admin.site.register(Article) 7 | admin.site.register(OptionalSizes) 8 | admin.site.register(OrphanedThumbs) 9 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | from django.test import TestCase 5 | 6 | 7 | TestCase.pytestmark = pytest.mark.django_db(transaction=True, reset_sequences=True) 8 | 9 | 10 | @pytest.fixture(autouse=True) 11 | def suppress_warnings(): 12 | warnings.simplefilter("error", Warning) 13 | warnings.filterwarnings('ignore', message='.*?ckeditor') 14 | warnings.filterwarnings('ignore', message='.*?collections') 15 | warnings.filterwarnings('ignore', message='.*?Resampling') 16 | warnings.filterwarnings('ignore', message='.*?distutils') 17 | # warning from grappelli 3.0 templates 18 | warnings.filterwarnings('ignore', message='.*?length_is') 19 | -------------------------------------------------------------------------------- /tests/data/animated-duration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/animated-duration.gif -------------------------------------------------------------------------------- /tests/data/animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/animated.gif -------------------------------------------------------------------------------- /tests/data/best-fit-off-by-one-bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/best-fit-off-by-one-bug.png -------------------------------------------------------------------------------- /tests/data/cmyk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/cmyk.jpg -------------------------------------------------------------------------------- /tests/data/img.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/img.jpg -------------------------------------------------------------------------------- /tests/data/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/img.png -------------------------------------------------------------------------------- /tests/data/img2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/img2.jpg -------------------------------------------------------------------------------- /tests/data/size-order-bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/size-order-bug.png -------------------------------------------------------------------------------- /tests/data/transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theatlantic/django-cropduster/0621b54713152934e1cfb7a99234565d07b177a1/tests/data/transparent.png -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | from io import open 2 | import tempfile 3 | import os 4 | import shutil 5 | import uuid 6 | 7 | import PIL.Image 8 | 9 | from django.core.files.storage import default_storage 10 | from django.core.files.base import ContentFile 11 | from django.test import override_settings 12 | 13 | from .utils import repr_rgb 14 | 15 | 16 | PATH = os.path.split(__file__)[0] 17 | ORIG_IMG_PATH = os.path.join(PATH, 'data') 18 | 19 | 20 | class CropdusterTestCaseMediaMixin(object): 21 | 22 | def _pre_setup(self): 23 | super(CropdusterTestCaseMediaMixin, self)._pre_setup() 24 | self.temp_media_root = tempfile.mkdtemp(prefix='TEST_MEDIA_ROOT_') 25 | self.override = override_settings(MEDIA_ROOT=self.temp_media_root) 26 | self.override.enable() 27 | 28 | def _post_teardown(self): 29 | if hasattr(default_storage, 'bucket'): 30 | default_storage.bucket.objects.filter(Prefix=default_storage.location).delete() 31 | shutil.rmtree(self.temp_media_root) 32 | self.override.disable() 33 | super(CropdusterTestCaseMediaMixin, self)._post_teardown() 34 | 35 | def setUp(self): 36 | super(CropdusterTestCaseMediaMixin, self).setUp() 37 | 38 | random = uuid.uuid4().hex 39 | self.TEST_IMG_DIR = ORIG_IMG_PATH 40 | self.TEST_IMG_DIR_RELATIVE = os.path.join(random, 'data') 41 | 42 | def assertImageColorEqual(self, element, image): 43 | self.selenium.execute_script('arguments[0].scrollIntoView()', element) 44 | scroll_top = -1 * self.selenium.execute_script( 45 | 'return document.body.getBoundingClientRect().top') 46 | tmp_file = tempfile.NamedTemporaryFile(suffix='.png') 47 | pixel_density = self.selenium.execute_script('return window.devicePixelRatio') or 1 48 | x1 = int(round(element.location['x'] + (element.size['width'] // 2.0))) 49 | y1 = int(round(element.location['y'] - scroll_top + (element.size['height'] // 2.0))) 50 | 51 | image_path = os.path.join(os.path.dirname(__file__), 'data', image) 52 | ref_im = PIL.Image.open(image_path).convert('RGB') 53 | w, h = ref_im.size 54 | x2, y2 = int(round(w // 2.0)), int(round(h // 2.0)) 55 | ref_rgb = ref_im.getpixel((x2, y2)) 56 | ref_im.close() 57 | 58 | def get_screenshot_rgb(): 59 | if not self.selenium.save_screenshot(tmp_file.name): 60 | raise Exception("Failed to save screenshot") 61 | im = PIL.Image.open(tmp_file.name).convert('RGB') 62 | rgb = im.getpixel((x1 * pixel_density, y1 * pixel_density)) 63 | im.close() 64 | return rgb 65 | 66 | self.wait_until( 67 | lambda d: get_screenshot_rgb() == ref_rgb, 68 | message=( 69 | "Colors differ: %s != %s" % (repr_rgb(ref_rgb), repr_rgb(get_screenshot_rgb())))) 70 | 71 | def create_unique_image(self, image): 72 | image_uuid = uuid.uuid4().hex 73 | 74 | ext = os.path.splitext(image)[1] 75 | image_name = os.path.join( 76 | self.TEST_IMG_DIR_RELATIVE, image_uuid, "original%s" % ext) 77 | preview_image_name = os.path.join( 78 | self.TEST_IMG_DIR_RELATIVE, image_uuid, "_preview%s" % ext) 79 | 80 | with open("%s/%s" % (ORIG_IMG_PATH, image), mode='rb') as f: 81 | default_storage.save(image_name, ContentFile(f.read())) 82 | 83 | with open("%s/%s" % (ORIG_IMG_PATH, image), mode='rb') as f: 84 | default_storage.save(preview_image_name, ContentFile(f.read())) 85 | 86 | return image_name 87 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from cropduster.fields import ReverseForeignRelation 4 | from cropduster.models import CropDusterField, Size 5 | 6 | 7 | class Author(models.Model): 8 | name = models.CharField(max_length=255) 9 | HEADSHOT_SIZES = [ 10 | Size('main', w=220, h=180, auto=[ 11 | Size('thumb', w=110, h=90), 12 | ]), 13 | ] 14 | headshot = CropDusterField(upload_to="author/headshots/%Y/%m", sizes=HEADSHOT_SIZES, 15 | related_name="author_headshotset") 16 | 17 | 18 | class Article(models.Model): 19 | title = models.CharField(max_length=255) 20 | author = models.ForeignKey(to=Author, blank=True, null=True, 21 | on_delete=models.SET_NULL) 22 | LEAD_IMAGE_SIZES = [ 23 | Size('main', w=600, h=480, auto=[ 24 | Size('thumb', w=110, h=90), 25 | ]), 26 | Size('no_height', w=600), 27 | ] 28 | ALT_IMAGE_SIZES = [ 29 | Size('wide', w=600, h=300), 30 | ] 31 | lead_image = CropDusterField(upload_to="article/lead_image/%Y/%m", 32 | db_column='image', 33 | related_name="test_article_lead_image", 34 | sizes=LEAD_IMAGE_SIZES) 35 | alt_image = CropDusterField(upload_to="article/alt_image/%Y/%m", 36 | related_name="test_article_alt_image", 37 | sizes=ALT_IMAGE_SIZES, 38 | field_identifier="alt") 39 | 40 | 41 | class OptionalSizes(models.Model): 42 | 43 | TEST_SIZES = [ 44 | Size('main', w=600, h=480, auto=[ 45 | Size('optional', w=1200, h=960, required=False), 46 | ])] 47 | 48 | slug = models.SlugField() 49 | image = CropDusterField(upload_to="test", sizes=TEST_SIZES) 50 | 51 | 52 | class OrphanedThumbs(models.Model): 53 | 54 | TEST_SIZES = [ 55 | Size('main', w=600, h=480, auto=[ 56 | Size('main@2x', w=1200, h=960), 57 | ]), 58 | Size('secondary', w=600, h=480, auto=[ 59 | Size('secondary@2x', w=1200, h=960), 60 | ])] 61 | 62 | slug = models.SlugField() 63 | image = CropDusterField(upload_to="test", sizes=TEST_SIZES) 64 | 65 | 66 | class MultipleFieldsInheritanceParent(models.Model): 67 | 68 | slug = models.SlugField() 69 | image = CropDusterField(upload_to="test", sizes=[Size('main', w=600, h=480)]) 70 | 71 | 72 | class MultipleFieldsInheritanceChild(MultipleFieldsInheritanceParent): 73 | 74 | image2 = CropDusterField(upload_to="test", sizes=[Size('main', w=600, h=480)], 75 | field_identifier="2") 76 | 77 | 78 | class ReverseForeignRelA(models.Model): 79 | slug = models.SlugField() 80 | c = models.ForeignKey('ReverseForeignRelC', on_delete=models.CASCADE) 81 | a_type = models.CharField(max_length=10, choices=( 82 | ("x", "X"), 83 | ("y", "Y"), 84 | ("z", "Z"), 85 | )) 86 | 87 | def __str__(self): 88 | return self.slug 89 | 90 | 91 | class ReverseForeignRelB(models.Model): 92 | slug = models.SlugField() 93 | c = models.ForeignKey('ReverseForeignRelC', on_delete=models.CASCADE) 94 | 95 | def __str__(self): 96 | return self.slug 97 | 98 | 99 | class ReverseForeignRelC(models.Model): 100 | slug = models.SlugField() 101 | rel_a = ReverseForeignRelation( 102 | ReverseForeignRelA, field_name='c', limit_choices_to={'a_type': 'x'}) 103 | rel_b = ReverseForeignRelation(ReverseForeignRelB, field_name='c') 104 | 105 | def __str__(self): 106 | return self.slug 107 | 108 | 109 | class ReverseForeignRelM2M(models.Model): 110 | slug = models.SlugField() 111 | m2m = models.ManyToManyField(ReverseForeignRelC) 112 | 113 | def __str__(self): 114 | return self.slug 115 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | import django 5 | from django.core.signals import setting_changed 6 | from django.dispatch import receiver 7 | from django.utils.functional import lazy 8 | from django.urls import reverse 9 | 10 | from selenosis.settings import * 11 | 12 | 13 | lazy_reverse = lazy(reverse, str) 14 | 15 | 16 | MIGRATION_MODULES = { 17 | 'auth': None, 18 | 'contenttypes': None, 19 | 'sessions': None, 20 | 'cropduster': None, 21 | } 22 | 23 | INSTALLED_APPS += ( 24 | 'generic_plus', 25 | 'cropduster', 26 | 'cropduster.standalone', 27 | 'tests', 28 | 'tests.standalone', 29 | 'ckeditor', 30 | ) 31 | 32 | ROOT_URLCONF = 'tests.urls' 33 | 34 | TEMPLATES[0]['OPTIONS']['debug'] = True 35 | 36 | if os.environ.get('S3') == '1': 37 | if django.VERSION >= (4, 2): 38 | STORAGES = { 39 | "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, 40 | "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, 41 | } 42 | else: 43 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 44 | AWS_STORAGE_BUCKET_NAME = 'ollie-cropduster-media-test-bucket-dev' 45 | AWS_LOCATION = 'cropduster/%s/' % uuid.uuid4().hex 46 | AWS_S3_SIGNATURE_VERSION = 's3v4' 47 | 48 | CKEDITOR_CONFIGS = { 49 | 'default': { 50 | 'extraPlugins': 'cropduster', 51 | 'removePlugins': 'flash,forms,contextmenu,liststyle,table,tabletools,iframe', 52 | 'disableAutoInline': True, 53 | "height": 450, 54 | "width": 840, 55 | 'cropduster_uploadTo': 'ckeditor', 56 | 'cropduster_previewSize': [570, 300], 57 | 'cropduster_url': lazy_reverse('cropduster-standalone'), 58 | 'cropduster_urlParams': {'max_w': 672, 'full_w': 960}, 59 | }, 60 | } 61 | 62 | CKEDITOR_UPLOAD_PATH = "%s/upload" % MEDIA_ROOT 63 | CROPDUSTER_CREATE_THUMBS = True 64 | USE_TZ = True 65 | 66 | 67 | @receiver(setting_changed) 68 | def reload_settings(**kwargs): 69 | if kwargs['setting'] == 'CROPDUSTER_CREATE_THUMBS': 70 | from cropduster import settings as cropduster_settings 71 | cropduster_settings.CROPDUSTER_CREATE_THUMBS = kwargs['value'] 72 | 73 | 74 | os.makedirs(CKEDITOR_UPLOAD_PATH) 75 | -------------------------------------------------------------------------------- /tests/standalone/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'tests.standalone.apps.StandaloneTestConfig' 2 | -------------------------------------------------------------------------------- /tests/standalone/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import StandaloneArticle 4 | 5 | 6 | admin.site.register(StandaloneArticle) 7 | -------------------------------------------------------------------------------- /tests/standalone/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StandaloneTestConfig(AppConfig): 5 | name = 'tests.standalone' 6 | label = 'standalone_test' 7 | -------------------------------------------------------------------------------- /tests/standalone/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ckeditor.fields import RichTextField 4 | 5 | 6 | class StandaloneArticle(models.Model): 7 | content = RichTextField(blank=True, config_name='default') 8 | -------------------------------------------------------------------------------- /tests/standalone/test_admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import contextlib 4 | import re 5 | import time 6 | from unittest import SkipTest 7 | import os 8 | 9 | import django 10 | from django.core.files.storage import default_storage 11 | from django.test import override_settings 12 | 13 | import PIL.Image 14 | from selenosis import AdminSelenosisTestCase 15 | 16 | from cropduster.models import Image, Thumb 17 | from tests.helpers import CropdusterTestCaseMediaMixin 18 | 19 | from .models import StandaloneArticle 20 | 21 | 22 | class TestStandaloneAdmin(CropdusterTestCaseMediaMixin, AdminSelenosisTestCase): 23 | 24 | root_urlconf = 'tests.urls' 25 | 26 | @property 27 | def available_apps(self): 28 | apps = [ 29 | 'django.contrib.auth', 30 | 'django.contrib.contenttypes', 31 | 'django.contrib.messages', 32 | 'django.contrib.sessions', 33 | 'django.contrib.sites', 34 | 'django.contrib.staticfiles', 35 | 'django.contrib.admin', 36 | 'generic_plus', 37 | 'cropduster', 38 | 'cropduster.standalone', 39 | 'tests', 40 | 'tests.standalone', 41 | 'ckeditor', 42 | 'selenosis', 43 | ] 44 | if self.has_grappelli: 45 | apps.insert(0, 'grappelli') 46 | return apps 47 | 48 | def _pre_setup(self): 49 | super(TestStandaloneAdmin, self)._pre_setup() 50 | self.ckeditor_override = override_settings( 51 | CKEDITOR_UPLOAD_PATH="%s/files/" % self.temp_media_root) 52 | self.ckeditor_override.enable() 53 | 54 | def _post_teardown(self): 55 | super(TestStandaloneAdmin, self)._post_teardown() 56 | self.ckeditor_override.disable() 57 | 58 | def setUp(self): 59 | if self.has_grappelli and django.VERSION >= (3, 2): 60 | raise SkipTest("django-ckeditor is not yet compatible with django 3.2+ and grappelli") 61 | super(TestStandaloneAdmin, self).setUp() 62 | self.is_s3 = os.environ.get('S3') == '1' 63 | 64 | @contextlib.contextmanager 65 | def switch_to_ckeditor_iframe(self): 66 | with self.visible_selector('.cke_editor_cropduster_content_dialog iframe') as iframe: 67 | time.sleep(1) 68 | self.selenium.switch_to.frame(iframe) 69 | yield iframe 70 | self.selenium.switch_to.parent_frame() 71 | 72 | @contextlib.contextmanager 73 | def open_cropduster_ckeditor_dialog(self): 74 | with self.clickable_selector('.cke_button__cropduster_icon') as el: 75 | el.click() 76 | 77 | with self.switch_to_ckeditor_iframe(): 78 | time.sleep(1) 79 | with self.visible_selector('#id_image'): 80 | yield 81 | 82 | def toggle_caption_checkbox(self): 83 | caption_checkbox_xpath = '//input[following-sibling::label[text()="Captioned image"]]' 84 | with self.clickable_xpath(caption_checkbox_xpath) as checkbox: 85 | checkbox.click() 86 | time.sleep(0.2) 87 | 88 | def cropduster_ckeditor_ok(self): 89 | with self.clickable_selector('.cke_dialog_ui_button_ok') as ok: 90 | ok.click() 91 | time.sleep(2 if self.is_s3 else 0.2) 92 | 93 | def test_basic_usage(self): 94 | self.load_admin(StandaloneArticle) 95 | 96 | with self.open_cropduster_ckeditor_dialog(): 97 | with self.visible_selector('#id_image') as el: 98 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.png')) 99 | with self.clickable_selector('#upload-button') as el: 100 | el.click() 101 | self.wait_until_visible_selector('#id_size-width') 102 | 103 | self.toggle_caption_checkbox() 104 | self.cropduster_ckeditor_ok() 105 | 106 | if self.is_s3: 107 | time.sleep(5) 108 | 109 | content_html = self.selenium.execute_script('return $("#id_content").val()') 110 | 111 | img_src_matches = re.search(r' src="([^"]+)"', content_html) 112 | self.assertIsNotNone(img_src_matches, "Image not found in content: %s" % content_html) 113 | image_url = img_src_matches.group(1) 114 | image_hash = re.search(r'img/([0-9a-f]+)\.png', image_url).group(1) 115 | 116 | try: 117 | image = Image.objects.get(image='ckeditor/img/original.png') 118 | except Image.DoesNotExist: 119 | raise AssertionError("Image not found in database") 120 | 121 | try: 122 | thumb = Thumb.objects.get(name=image_hash, image=image) 123 | except Thumb.DoesNotExist: 124 | raise AssertionError("Thumb not found in database") 125 | 126 | self.assertEqual( 127 | list(Thumb.objects.all()), [thumb], 128 | "Exactly one Thumb object should have been created") 129 | 130 | self.assertHTMLEqual( 131 | content_html, 132 | """ 133 |
    134 | 135 |
    Caption
    136 |
    137 |

     

    138 | """ % image_url) 139 | 140 | def test_dialog_change_width(self): 141 | """ 142 | Test that changing the width in the cropduster CKEDITOR dialog produces 143 | an image and html with the correct dimensions 144 | """ 145 | self.load_admin(StandaloneArticle) 146 | 147 | with self.open_cropduster_ckeditor_dialog(): 148 | with self.visible_selector('#id_image') as el: 149 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.png')) 150 | with self.clickable_selector('#upload-button') as el: 151 | el.click() 152 | time.sleep(1) 153 | with self.clickable_selector('#id_size-width') as el: 154 | el.send_keys(300) 155 | 156 | self.toggle_caption_checkbox() 157 | self.cropduster_ckeditor_ok() 158 | 159 | if self.is_s3: 160 | time.sleep(5) 161 | 162 | content_html = self.selenium.execute_script('return $("#id_content").val()') 163 | 164 | img_src_matches = re.search(r' src="([^"]+)"', content_html) 165 | self.assertIsNotNone(img_src_matches, "Image not found in content: %s" % content_html) 166 | image_url = img_src_matches.group(1) 167 | image_hash = re.search(r'img/([0-9a-f]+)\.png', image_url).group(1) 168 | 169 | try: 170 | image = Image.objects.get(image='ckeditor/img/original.png') 171 | except Image.DoesNotExist: 172 | raise AssertionError("Image not found in database") 173 | 174 | try: 175 | thumb = Thumb.objects.get(name=image_hash, image=image) 176 | except Thumb.DoesNotExist: 177 | raise AssertionError("Thumb not found in database") 178 | 179 | self.assertEqual( 180 | list(Thumb.objects.all()), [thumb], 181 | "Exactly one Thumb object should have been created") 182 | 183 | with default_storage.open("ckeditor/img/%s.png" % image_hash, mode='rb') as f: 184 | self.assertEqual(PIL.Image.open(f).size, (300, 356)) 185 | 186 | self.assertHTMLEqual( 187 | content_html, 188 | """ 189 |
    190 | 191 |
    Caption
    192 |
    193 |

     

    194 | """ % image_url) 195 | -------------------------------------------------------------------------------- /tests/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 6 | 7 | 8 |

    404

    9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 500 6 | 7 | 8 |

    500

    9 | 10 | 11 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | 5 | from django.core.files.storage import default_storage 6 | from selenosis import AdminSelenosisTestCase 7 | 8 | from cropduster.models import Image, Size 9 | from .helpers import CropdusterTestCaseMediaMixin 10 | from .models import Article, Author, OptionalSizes 11 | 12 | 13 | class TestAdmin(CropdusterTestCaseMediaMixin, AdminSelenosisTestCase): 14 | 15 | root_urlconf = 'tests.urls' 16 | 17 | @property 18 | def available_apps(self): 19 | apps = [ 20 | 'django.contrib.auth', 21 | 'django.contrib.contenttypes', 22 | 'django.contrib.messages', 23 | 'django.contrib.sessions', 24 | 'django.contrib.sites', 25 | 'django.contrib.staticfiles', 26 | 'django.contrib.admin', 27 | 'generic_plus', 28 | 'cropduster', 29 | 'tests', 30 | 'tests.standalone', 31 | 'selenosis', 32 | ] 33 | if self.has_grappelli: 34 | apps.insert(0, 'grappelli') 35 | return apps 36 | 37 | def test_addform_single_image(self): 38 | from selenium.webdriver.common.by import By 39 | 40 | self.load_admin(Author) 41 | 42 | browser = self.selenium 43 | browser.find_element(By.ID, 'id_name').send_keys('Mark Twain') 44 | with self.clickable_selector('#headshot-group .cropduster-button') as el: 45 | el.click() 46 | 47 | with self.switch_to_popup_window(): 48 | with self.visible_selector('#id_image') as el: 49 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.png')) 50 | with self.clickable_selector('#upload-button') as el: 51 | el.click() 52 | with self.clickable_selector('#crop-button') as el: 53 | el.click() 54 | 55 | self.save_form() 56 | 57 | author = Author.objects.all()[0] 58 | sizes = list(Size.flatten(Author.HEADSHOT_SIZES)) 59 | self.assertTrue(bool(author.headshot.name)) 60 | 61 | image = author.headshot.related_object 62 | thumbs = image.thumbs.all() 63 | self.assertEqual(len(thumbs), len(sizes)) 64 | main_thumb = image.thumbs.get(name='main') 65 | self.assertEqual(main_thumb.to_dict(), { 66 | 'reference_thumb_id': None, 67 | 'name': 'main', 68 | 'width': 220, 69 | 'height': 180, 70 | 'crop_w': 674, 71 | 'crop_h': 551, 72 | 'crop_x': 0, 73 | 'crop_y': 125, 74 | 'image_id': image.pk, 75 | 'id': main_thumb.pk, 76 | }) 77 | auto_thumb = image.thumbs.get(name='thumb') 78 | self.assertEqual(auto_thumb.to_dict(), { 79 | 'reference_thumb_id': main_thumb.pk, 80 | 'name': 'thumb', 81 | 'width': 110, 82 | 'height': 90, 83 | 'crop_w': None, 84 | 'crop_h': None, 85 | 'crop_x': None, 86 | 'crop_y': None, 87 | 'image_id': image.pk, 88 | 'id': auto_thumb.pk, 89 | }) 90 | self.assertTrue(default_storage.exists(auto_thumb.image_name)) 91 | 92 | def test_addform_multiple_image(self): 93 | from selenium.webdriver.common.by import By 94 | 95 | author = Author.objects.create(name="Mark Twain") 96 | self.load_admin(Article) 97 | browser = self.selenium 98 | browser.find_element(By.ID, 'id_title').send_keys("A Connecticut Yankee in King Arthur's Court") 99 | 100 | # Upload and crop first Image 101 | browser.find_element(By.CSS_SELECTOR, '#lead_image-group .cropduster-button').click() 102 | 103 | with self.switch_to_popup_window(): 104 | with self.visible_selector('#id_image') as el: 105 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.jpg')) 106 | with self.clickable_selector('#upload-button') as el: 107 | el.click() 108 | with self.clickable_selector('#crop-button') as el: 109 | el.click() 110 | with self.clickable_selector('#crop-button:not(.disabled)') as el: 111 | el.click() 112 | 113 | # Upload and crop second Image 114 | with self.clickable_selector('#alt_image-group .cropduster-button') as el: 115 | # With the Chrome driver, using Grappelli, this button can be covered 116 | # by the fixed footer. So we scroll the button into view. 117 | browser.execute_script('window.scrollTo(0, %d)' % el.location['y']) 118 | el.click() 119 | 120 | with self.switch_to_popup_window(): 121 | with self.visible_selector('#id_image') as el: 122 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.png')) 123 | with self.clickable_selector('#upload-button') as el: 124 | el.click() 125 | with self.clickable_selector('#crop-button') as el: 126 | el.click() 127 | 128 | # Add required FK 129 | browser.find_element(By.XPATH, '//select[@id="id_author"]/option[@value=%d]' % author.pk).click() 130 | 131 | self.save_form() 132 | 133 | # Test that crops saved correctly 134 | article = Article.objects.all()[0] 135 | lead_sizes = list(Size.flatten(Article.LEAD_IMAGE_SIZES)) 136 | alt_sizes = list(Size.flatten(Article.ALT_IMAGE_SIZES)) 137 | 138 | self.assertTrue(article.lead_image.name.endswith('.jpg')) 139 | self.assertEqual(len(article.lead_image.related_object.thumbs.all()), len(lead_sizes)) 140 | self.assertTrue(article.alt_image.name.endswith('.png')) 141 | self.assertEqual(len(article.alt_image.related_object.thumbs.all()), len(alt_sizes)) 142 | 143 | def test_changeform_single_image(self): 144 | from selenium.webdriver.common.by import By 145 | 146 | image_path = self.create_unique_image('img.png') 147 | author = Author.objects.create(name="Samuel Langhorne Clemens", 148 | headshot=image_path) 149 | Image.objects.create(image=image_path, content_object=author) 150 | author.refresh_from_db() 151 | author.headshot.generate_thumbs() 152 | 153 | self.load_admin(author) 154 | 155 | preview_image_el = self.selenium.find_element(By.CSS_SELECTOR, '#headshot-group .cropduster-image-thumb') 156 | src_image_path = os.path.join(self.TEST_IMG_DIR, 'img.png') 157 | self.assertImageColorEqual(preview_image_el, src_image_path) 158 | 159 | elem = self.selenium.find_element(By.ID, 'id_name') 160 | elem.clear() 161 | elem.send_keys("Mark Twain") 162 | 163 | self.save_form() 164 | 165 | author = Author.objects.get(pk=author.pk) 166 | self.assertEqual(author.name, 'Mark Twain') 167 | self.assertEqual(author.headshot.name, image_path) 168 | self.assertEqual(len(author.headshot.related_object.thumbs.all()), 2) 169 | 170 | def test_changeform_multiple_images(self): 171 | from selenium.webdriver.common.by import By 172 | 173 | author = Author.objects.create(name="Samuel Langhorne Clemens") 174 | lead_image_path = self.create_unique_image('img.jpg') 175 | alt_image_path = self.create_unique_image('img.png') 176 | article = Article.objects.create(title="title", author=author, 177 | lead_image=lead_image_path, 178 | alt_image=alt_image_path) 179 | Image.objects.create(image=lead_image_path, content_object=article) 180 | Image.objects.create( 181 | image=alt_image_path, content_object=article, field_identifier='alt') 182 | article.refresh_from_db() 183 | article.lead_image.generate_thumbs() 184 | article.alt_image.generate_thumbs() 185 | 186 | self.load_admin(article) 187 | 188 | elem = self.selenium.find_element(By.ID, 'id_title') 189 | elem.clear() 190 | elem.send_keys("Updated Title") 191 | 192 | self.save_form() 193 | 194 | article.refresh_from_db() 195 | self.assertEqual(article.title, 'Updated Title') 196 | self.assertEqual(article.lead_image.name, lead_image_path) 197 | self.assertEqual(article.alt_image.name, alt_image_path) 198 | self.assertEqual(len(article.lead_image.related_object.thumbs.all()), 3) 199 | self.assertEqual(len(article.alt_image.related_object.thumbs.all()), 1) 200 | 201 | def test_changeform_with_optional_sizes_small_image(self): 202 | test_a = OptionalSizes.objects.create(slug='a') 203 | 204 | self.load_admin(test_a) 205 | 206 | # Upload and crop image 207 | with self.clickable_selector('#image-group .cropduster-button') as el: 208 | # With the Chrome driver, using Grappelli, this button can be covered 209 | # by the fixed footer. So we scroll the button into view. 210 | self.selenium.execute_script('window.scrollTo(0, %d)' % el.location['y']) 211 | el.click() 212 | 213 | with self.switch_to_popup_window(): 214 | with self.visible_selector('#id_image') as el: 215 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img.jpg')) 216 | with self.clickable_selector('#upload-button') as el: 217 | el.click() 218 | with self.clickable_selector('#crop-button') as el: 219 | el.click() 220 | 221 | self.save_form() 222 | 223 | test_a = OptionalSizes.objects.get(slug='a') 224 | image = test_a.image.related_object 225 | num_thumbs = len(image.thumbs.all()) 226 | self.assertEqual(num_thumbs, 1, "Expected one thumb; instead got %d" % num_thumbs) 227 | 228 | def test_changeform_with_optional_sizes_large_image(self): 229 | test_a = OptionalSizes.objects.create(slug='a') 230 | self.load_admin(test_a) 231 | 232 | # Upload and crop image 233 | with self.clickable_selector('#image-group .cropduster-button') as el: 234 | # With the Chrome driver, using Grappelli, this button can be covered 235 | # by the fixed footer. So we scroll the button into view. 236 | self.selenium.execute_script('window.scrollTo(0, %d)' % el.location['y']) 237 | el.click() 238 | 239 | with self.switch_to_popup_window(): 240 | with self.visible_selector('#id_image') as el: 241 | el.send_keys(os.path.join(self.TEST_IMG_DIR, 'img2.jpg')) 242 | with self.clickable_selector('#upload-button') as el: 243 | el.click() 244 | with self.clickable_selector('#crop-button') as el: 245 | el.click() 246 | 247 | self.save_form() 248 | 249 | test_a = OptionalSizes.objects.get(slug='a') 250 | image = test_a.image.related_object 251 | num_thumbs = len(image.thumbs.all()) 252 | self.assertEqual(num_thumbs, 2, "Expected one thumb; instead got %d" % num_thumbs) 253 | -------------------------------------------------------------------------------- /tests/test_gifsicle.py: -------------------------------------------------------------------------------- 1 | from io import open, BytesIO 2 | import os 3 | import shutil 4 | import tempfile 5 | 6 | from PIL import Image, ImageSequence 7 | from io import BytesIO 8 | 9 | from django import test 10 | from django.core.files.storage import default_storage 11 | from django.conf import settings 12 | 13 | from .helpers import CropdusterTestCaseMediaMixin 14 | 15 | 16 | class TestUtilsImage(CropdusterTestCaseMediaMixin, test.TestCase): 17 | 18 | def _get_img(self, filename): 19 | return Image.open(os.path.join(self.TEST_IMG_DIR, filename)) 20 | 21 | def test_is_animated_gif(self): 22 | from cropduster.utils import is_animated_gif 23 | with self._get_img('animated.gif') as yes: 24 | with self._get_img('img.jpg') as no: 25 | self.assertTrue(is_animated_gif(yes)) 26 | self.assertFalse(is_animated_gif(no)) 27 | 28 | -------------------------------------------------------------------------------- /tests/test_resizing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django import test 4 | 5 | from .helpers import CropdusterTestCaseMediaMixin 6 | from cropduster.resizing import Crop, Box, Size 7 | 8 | 9 | class TestResizing(CropdusterTestCaseMediaMixin, test.TestCase): 10 | 11 | def test_off_by_one_bug(self): 12 | img_path = self.create_unique_image('best-fit-off-by-one-bug.png') 13 | crop = Crop(Box(x1=0, y1=0, x2=960, y2=915), img_path) 14 | size = Size('960', w=960, h=594) 15 | new_crop = size.fit_to_crop(crop) 16 | self.assertNotEqual(new_crop.box.h, 593, "Calculated best fit height is 1 pixel too short") 17 | self.assertEqual(new_crop.box.h, 594) 18 | 19 | def test_size_order_bug(self): 20 | img_path = self.create_unique_image('size-order-bug.png') 21 | crop = Crop(Box(x1=160, y1=0, x2=800, y2=640), img_path) 22 | size = Size('650', w=650, min_h=250) 23 | new_crop = size.fit_to_crop(crop) 24 | self.assertGreaterEqual(new_crop.box.w, 650, 25 | "Calculated best fit (%d) didn't get required width (650)" % new_crop.box.w) 26 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from io import open, BytesIO 2 | import os 3 | import shutil 4 | import tempfile 5 | 6 | from PIL import Image 7 | 8 | from django import test 9 | from django.core.files.storage import default_storage 10 | from django.conf import settings 11 | 12 | from .helpers import CropdusterTestCaseMediaMixin 13 | 14 | 15 | class TestUtilsImage(CropdusterTestCaseMediaMixin, test.TestCase): 16 | 17 | def _get_img(self, filename): 18 | return Image.open(os.path.join(self.TEST_IMG_DIR, filename)) 19 | 20 | def test_that_test_work(self): 21 | self.assertEqual(True, True) 22 | 23 | def test_get_image_extension(self): 24 | from cropduster.utils import get_image_extension 25 | 26 | with self._get_img('img.jpg') as im: 27 | assert get_image_extension(im) == ".jpg" 28 | with self._get_img('img.png') as im: 29 | assert get_image_extension(im) == ".png" 30 | with self._get_img('animated.gif') as im: 31 | assert get_image_extension(im) == ".gif" 32 | 33 | tmp_jpg_bad_ext_pdf = tempfile.NamedTemporaryFile(suffix='.pdf') 34 | with open(os.path.join(self.TEST_IMG_DIR, 'img.jpg'), mode='rb') as f: 35 | tmp_jpg_bad_ext_pdf.write(f.read()) 36 | tmp_jpg_bad_ext_pdf.seek(0) 37 | with Image.open(tmp_jpg_bad_ext_pdf.name) as im: 38 | assert get_image_extension(im) == ".jpg" 39 | tmp_jpg_bad_ext_pdf.close() 40 | 41 | def test_is_transparent(self): 42 | from cropduster.utils import is_transparent 43 | with self._get_img('transparent.png') as im: 44 | assert is_transparent(im) is True 45 | with self._get_img('img.png') as im: 46 | assert is_transparent(im) is False 47 | 48 | def test_correct_colorspace(self): 49 | from cropduster.utils import correct_colorspace 50 | with self._get_img('cmyk.jpg') as img: 51 | self.assertEqual(img.mode, 'CMYK') 52 | converted = correct_colorspace(img) 53 | self.assertEqual(img.mode, 'CMYK') 54 | self.assertEqual(converted.mode, 'RGB') 55 | 56 | def test_is_animated_gif(self): 57 | from cropduster.utils import is_animated_gif 58 | with self._get_img('animated.gif') as yes: 59 | with self._get_img('img.jpg') as no: 60 | self.assertTrue(is_animated_gif(yes)) 61 | self.assertFalse(is_animated_gif(no)) 62 | 63 | 64 | class TestUtilsPaths(CropdusterTestCaseMediaMixin, test.TestCase): 65 | 66 | def test_get_upload_foldername(self): 67 | import uuid 68 | from cropduster.utils import get_upload_foldername 69 | 70 | path = random = uuid.uuid4().hex 71 | folder_path = get_upload_foldername('my img.jpg', upload_to=path) 72 | self.assertEqual(folder_path, "%s/my_img" % (path)) 73 | default_storage.save("%s/original.jpg" % folder_path, BytesIO(b'')) 74 | self.assertEqual(get_upload_foldername('my img.jpg', upload_to=path), 75 | os.path.join(path, 'my_img-1')) 76 | 77 | def test_get_min_size(self): 78 | from cropduster.utils import get_min_size 79 | from cropduster.resizing import Size 80 | 81 | sizes = [ 82 | Size('a', w=200, h=200), 83 | Size('b', w=100, h=300), 84 | Size('c', w=20, h=20) 85 | ] 86 | self.assertEqual(get_min_size(sizes), (200, 300)) 87 | 88 | sizes = [ 89 | Size('a', min_w=200, min_h=200, max_h=500), 90 | Size('b', min_w=100, min_h=300), 91 | Size('c', w=20, h=20) 92 | ] 93 | self.assertEqual(get_min_size(sizes), (200, 300)) 94 | 95 | def test_get_media_path(self): 96 | from generic_plus.utils import get_media_path 97 | 98 | img_name = '/test/some-test-image.jpg' 99 | from_url = settings.MEDIA_URL + img_name 100 | to_url = settings.MEDIA_ROOT + img_name 101 | self.assertEqual(get_media_path(from_url), to_url) 102 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django import test 4 | from django.core.files.storage import default_storage 5 | try: 6 | from django.urls import reverse 7 | except ImportError: 8 | from django.core.urlresolvers import reverse 9 | from django.contrib.auth.models import User 10 | from django.http import HttpRequest 11 | 12 | from cropduster import views 13 | from cropduster.utils import json 14 | 15 | from .helpers import CropdusterTestCaseMediaMixin 16 | 17 | 18 | class CropdusterViewTestRunner(CropdusterTestCaseMediaMixin, test.TestCase): 19 | def setUp(self): 20 | super(CropdusterViewTestRunner, self).setUp() 21 | self.factory = test.RequestFactory() 22 | self.user = User.objects.create_superuser('test', 23 | 'test@test.com', 'password') 24 | 25 | 26 | class TestIndex(CropdusterViewTestRunner): 27 | 28 | def test_get_is_200(self): 29 | request = self.factory.get(reverse('cropduster-index')) 30 | request.user = self.user 31 | response = views.index(request) 32 | self.assertEqual(response.status_code, 200) 33 | 34 | def test_post_is_405(self): 35 | request = self.factory.post(reverse('cropduster-index'), {}) 36 | request.user = self.user 37 | response = views.index(request) 38 | self.assertEqual(response.status_code, 405) 39 | 40 | 41 | class TestUpload(CropdusterViewTestRunner): 42 | 43 | def test_get_request(self): 44 | request = HttpRequest() 45 | request.method = "GET" 46 | request.user = self.user 47 | self.assertEqual( 48 | views.upload(request).content, 49 | views.index(request).content) 50 | 51 | def test_post_request(self): 52 | with open(os.path.join(self.TEST_IMG_DIR, 'img.jpg'), 'rb') as img_file: 53 | data = { 54 | 'image': img_file, 55 | 'upload_to': ['test'], 56 | 'image_element_id': 'mt_image', 57 | 'md5': '', 58 | 'preview_height': '500', 59 | 'preview_width': '800', 60 | 'sizes': ''' 61 | [{ 62 | "auto": [{ 63 | "max_w": null, 64 | "retina": 0, 65 | "min_h": 1, 66 | "name": "lead", 67 | "w": 570, 68 | "h": null, 69 | "min_w": 570, 70 | "__type__": "Size", 71 | "max_h": null, 72 | "label": "Lead" 73 | }, { 74 | "max_w": null, 75 | "retina": 0, 76 | "min_h": 110, 77 | "name": "featured_small", 78 | "w": 170, 79 | "h": 110, 80 | "min_w": 170, 81 | "__type__": "Size", 82 | "max_h": null, 83 | "label": "Featured Small" 84 | }, { 85 | "max_w": null, 86 | "retina": 0, 87 | "min_h": 250, 88 | "name": "featured_large", 89 | "w": 386, 90 | "h": 250, 91 | "min_w": 386, 92 | "__type__": "Size", 93 | "max_h": null, 94 | "label": "Featured Large" 95 | }], 96 | "retina": 0, 97 | "name": "lead_large", 98 | "h": null, 99 | "min_w": 615, 100 | "__type__": "Size", 101 | "max_h": null, 102 | "label": "Lead Large", 103 | "max_w": null, 104 | "min_h": 250, 105 | "w": 615 106 | }]''', 107 | } 108 | request = self.factory.post(reverse('cropduster-upload'), data) 109 | request.user = self.user 110 | response = views.upload(request) 111 | self.assertEqual(response.status_code, 200) 112 | data = json.loads(response.content) 113 | self.assertTrue(default_storage.exists(data['orig_image'])) 114 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, re_path 2 | from django.contrib import admin 3 | 4 | 5 | admin.autodiscover() 6 | 7 | urlpatterns = [ 8 | re_path(r"^cropduster/", include("cropduster.urls")), 9 | re_path(r'^ckeditor/', include('ckeditor.urls')), 10 | re_path(r'^admin/', admin.site.urls), 11 | ] 12 | 13 | try: 14 | import grappelli.urls 15 | except ImportError: 16 | pass 17 | else: 18 | urlpatterns += [re_path(r"^grappelli/", include(grappelli.urls))] 19 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | def esc_code(codes=None): 2 | if codes is None: 3 | # reset escape code 4 | return "\x1b[0m" 5 | if not isinstance(codes, (list, tuple)): 6 | codes = [codes] 7 | return '\x1b[0;' + ';'.join(map(str, codes)) + 'm' 8 | 9 | 10 | def get_luminance(rgb): 11 | rgb_map = [] 12 | for val in rgb: 13 | val = val / 256 14 | if val <= 0.03928: 15 | rgb_map.append(val / 12.92) 16 | else: 17 | rgb_map.append(pow((val + 0.055) / 1.055, 2.4)) 18 | 19 | return (0.2126 * rgb_map[0]) + (0.7152 * rgb_map[1]) + (0.0722 * rgb_map[2]) 20 | 21 | 22 | def repr_rgb(rgb): 23 | r, g, b = rgb 24 | codes = (48, 2, r, g, b) 25 | reset = "\x1b[0m" 26 | hex_color = "#%s" % ("".join(["%02x" % c for c in rgb])) 27 | luminance = get_luminance(rgb) 28 | if luminance > 0.5: 29 | codes += (38, 2, 0, 0, 0) 30 | else: 31 | codes += (38, 2, 255, 255, 255) 32 | 33 | return "%(codes)s%(hex)s%(reset)s" % { 34 | 'codes': esc_code(codes), 35 | 'hex': hex_color, 36 | 'reset': reset, 37 | } 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39}-dj22-{grp,nogrp} 4 | py{37,38,39,310}-dj32-{grp,nogrp} 5 | py{38,39,310,311}-dj40-{grp,nogrp} 6 | py{38,39,310,311}-dj42-nogrp 7 | skipsdist = true 8 | 9 | [gh-actions] 10 | python = 11 | 3.7: py37 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 17 | [gh-actions:env] 18 | DJANGO = 19 | 2.2: dj22 20 | 3.2: dj32 21 | 4.0: dj40 22 | 4.2: dj42 23 | GRAPPELLI = 24 | 0: nogrp 25 | 1: grp 26 | 27 | [testenv] 28 | commands = 29 | pytest --junitxml={toxinidir}/reports/test-{envname}.xml {posargs} 30 | usedevelop = True 31 | setenv = 32 | COVERAGE_FILE={toxworkdir}/coverage/.coverage.{envname} 33 | passenv = 34 | CI 35 | TRAVIS 36 | TRAVIS_* 37 | DEFAULT_FILE_STORAGE 38 | AWS_ACCESS_KEY_ID 39 | AWS_SECRET_ACCESS_KEY 40 | S3 41 | deps = 42 | -e . 43 | pytest 44 | pytest-cov 45 | pytest-django 46 | selenium 47 | django-selenosis 48 | boto3 49 | coverage 50 | django-polymorphic 51 | dj22: django-storages==1.11.1 52 | !dj22: django-storages 53 | dj22: Django>=2.2,<3.0 54 | dj32: Django>=3.2,<4.0 55 | dj42: Django>=4.2,<5.0 56 | dj22-grp: django-grappelli>=2.13,<2.14 57 | dj32-grp: django-grappelli>=2.15,<2.16 58 | dj40-grp: django-grappelli>=3.0,<3.1 59 | lxml 60 | -e git+https://github.com/theatlantic/django-ckeditor.git@v4.5.7+atl.8.4\#egg=django-ckeditor 61 | 62 | [testenv:coverage-report] 63 | skip_install = true 64 | deps = coverage 65 | setenv=COVERAGE_FILE=.coverage 66 | changedir = {toxworkdir}/coverage 67 | commands = 68 | coverage combine 69 | coverage report 70 | coverage xml 71 | 72 | [testenv:codecov] 73 | skip_install = true 74 | deps = codecov 75 | depends = coverage-report 76 | passenv = CODECOV_TOKEN 77 | changedir = {toxinidir} 78 | commands = 79 | codecov --file {toxworkdir}/coverage/coverage.xml {posargs} 80 | --------------------------------------------------------------------------------