├── .coveragerc ├── .dockerignore ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── betty ├── __init__.py ├── authtoken │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── create_token.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ └── models.py ├── celery.py ├── conf │ ├── __init__.py │ ├── app.py │ ├── server.py │ └── urls.py ├── contrib │ ├── __init__.py │ └── cacheflush │ │ ├── __init__.py │ │ └── cachemaster.py ├── cropper │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── decorators.py │ │ ├── urls.py │ │ └── views.py │ ├── dssim.py │ ├── flush.py │ ├── font │ │ └── OpenSans-Semibold.ttf │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── change_storage_root.py │ │ │ ├── make_disk_storage_paths_absolute.py │ │ │ └── optimize_images.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0001_squashed_0004_auto_20160317_1706.py │ │ ├── 0002_auto_20141203_2115.py │ │ ├── 0002_image_last_modified.py │ │ ├── 0003_image_jpeg_qualities.py │ │ ├── 0004_auto_20160317_1706.py │ │ └── __init__.py │ ├── models.py │ ├── storage.py │ ├── tasks.py │ ├── templates │ │ ├── 404.html │ │ ├── 500.html │ │ └── image.js │ ├── urls.py │ ├── utils │ │ ├── __init__.py │ │ ├── http.py │ │ ├── placeholder.py │ │ └── runner.py │ └── views.py ├── image_browser │ ├── __init__.py │ ├── static │ │ └── betty │ │ │ ├── css │ │ │ ├── Jcrop.gif │ │ │ ├── bootstrap.min.css │ │ │ ├── jquery.Jcrop.min.css │ │ │ └── style.css │ │ │ ├── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.svg │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ └── glyphicons-halflings-regular.woff │ │ │ └── js │ │ │ ├── bootstrap.min.js │ │ │ ├── crop.js │ │ │ ├── index.js │ │ │ ├── jquery.Jcrop.min.js │ │ │ ├── jquery.color.js │ │ │ ├── jquery.infinitescroll.min.js │ │ │ ├── jquery.min.js │ │ │ ├── manual-trigger.js │ │ │ └── upload.js │ ├── templates │ │ ├── crop.html │ │ ├── index.html │ │ ├── registration │ │ │ └── login.html │ │ └── upload.html │ ├── urls.py │ └── views.py └── wsgi.py ├── conftest.py ├── docker-compose.yml ├── requirements ├── common.txt ├── dev.txt ├── imgmin.txt └── s3.txt ├── scripts ├── build ├── clean ├── init ├── install ├── lint ├── release ├── serve ├── shell └── test ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── betty.testconf.py ├── images ├── Header-Just_How.jpg ├── Lenna.png ├── Lenna.psd ├── Lenna.tiff ├── Lenna_cmyk.jpg ├── Sam_Hat1.jpg ├── Sam_Hat1.png ├── Sam_Hat1_gray.jpg ├── Sam_Hat1_noext ├── Simpsons-Week_a.jpg ├── animated.gif ├── huge.jpg └── tumblr.jpg ├── test_animated.py ├── test_api.py ├── test_auth.py ├── test_contrib.py ├── test_crop_utils.py ├── test_cropping.py ├── test_flush.py ├── test_image_files.py ├── test_image_model.py ├── test_image_urls.py ├── test_imgmin.py ├── test_management_commands.py ├── test_source.py └── test_storage.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *migrations/*,betty/cropped/*,betty/image_browser/*,betty/storage.py -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | __pycache__/ 3 | 4 | .git* 5 | 6 | .dockerignore 7 | Dockerfile 8 | docker-compose.yml 9 | 10 | .travis.yml 11 | 12 | # IDE stuff 13 | .ropeproject 14 | .idea 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.py[co] 3 | *.egg-info 4 | lib 5 | bin 6 | dist 7 | include 8 | .Python 9 | .DS_Store 10 | .cache/ 11 | betty.conf.py 12 | betty.db 13 | share/ 14 | images/ 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | python: 4 | - '2.7' 5 | - '3.5' 6 | services: 7 | - docker 8 | before_install: 9 | # Set base Python image version (leverages Travis build matrix) 10 | - sed -i'' "s/^\(FROM python:\).*/\1${TRAVIS_PYTHON_VERSION}/" Dockerfile 11 | install: 12 | - docker build --tag betty . 13 | script: 14 | - docker run betty py.test --cov betty --cov-report term-missing 15 | - docker run betty flake8 . 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Betty Cropper Change Log 2 | 3 | ## Version 2.6.1 4 | 5 | - Fix: `source` endpoint now supports returning original TIFF assets 6 | 7 | ## Version 2.6.0 8 | 9 | - Add new '//source' route to obtain original source asset. Image format specified in 'Content-Type' response 10 | header. 11 | 12 | ## Version 2.5.5 13 | 14 | - Fix image metadata caching race conditions 15 | 16 | ## Version 2.5.4 17 | 18 | - Make best effort to load corrupt images via Pillow's `ImageFile.LOAD_TRUNCATED_IMAGES` setting. 19 | 20 | ## Version 2.5.3 21 | 22 | - Fix `Image.clear_crops()` to handle when save crops path is different than image source path. Previously was preventing saved-to-disk crops from being deleted. 23 | 24 | ## Version 2.5.2 25 | 26 | - Image source caching attempts to use named cache "storage", else "default". 27 | 28 | ## Version 2.5.0 29 | 30 | - Add storage caching layer (ex: memcached in front of S3 backend) to improve performance with slower storage backends. 31 | Cache time controlled by new `BETTY_CACHE_STORAGE_SEC` setting (default: 1 hour). 32 | 33 | ## Version 2.4.1 34 | 35 | - Fix `image.js` function `computeAspectRatio` to always return a valid ratio. Previously there was an edge case where it would 36 | return `undefined`, resulting in a bad image URL. 37 | 38 | ## Version 2.4.0 39 | 40 | - Added management command `make_disk_storage_paths_absolute` to convert legacy relative disk paths to absolute paths. 41 | 42 | ## Version 2.3.1 43 | 44 | - Fix: Cache flush callback now also includes CLIENT_ONLY_WIDTHS 45 | 46 | ## Version 2.3.0 47 | 48 | - Added "If-Modified-Since" / 304 request header support to avoid accessing backend storage on cache re-validation. 49 | 50 | **Requires migration (add column `Image.last_modified`)** 51 | 52 | ## Version 2.2.0 53 | 54 | - Allow optional alternate cache duration for non-breakpoint crop widths (i.e. width not in `settings.BETTY_WIDTHS` and 55 | `settings.BETTY_CLIENT_ONLY_WIDTHS`) via new `settings.BETTY_CACHE_CROP_NON_BREAKPOINT_SEC`. 56 | - This allows breakpoint-widths to set very long cache times because they will be included in the itemized cache flush callback. 57 | - If `settings.BETTY_CACHE_CROP_NON_BREAKPOINT_SEC` not set, will use `settings.BETTY_CACHE_CROP_SEC`. 58 | 59 | ## Version 2.1.1 60 | 61 | - Packaging/install fix 62 | 63 | ## Version 2.1.0 64 | - Improvements to BETTY_CACHE_FLUSHER support: 65 | - `BETTY_CACHE_FLUSHER` can either be set to a callable object or a string import path 66 | - Flusher now passed list of string URLS instead of individual strings. This allows for more efficient callback batching logic. 67 | - Added reference cache flusher `betty.contrib.cacheflush.cachemaster` 68 | - `Image.clear_crops()` now includes animated files (`.../animated/original.{gif,jpg}`) 69 | - Add optional Docker support 70 | 71 | ## Version 2.0.6 72 | 73 | - Allow configurable "Image JS" cache time via `settings.BETTY_CACHE_IMAGEJS_SEC`. ImageJS requests are cheap but make up over 50% of current production requests, and only rarely changes on deploys. 74 | - Increase (rarely called) "crop redireect" cache times to 1 hour, a good balance between fewer requests and not overcommitting in case this ever changes. 75 | 76 | ## Version 2.0.5 77 | 78 | - Fixes max-width resize regression for cases other than "JPG mode=RGB". Switch to IO buffers requires passing 79 | a `format` value since no longer a filename to auto-detect via extension. 80 | 81 | ## Version 2.0.4 82 | 83 | - Management command `change_storage_root` uses older option format for compatibility with Django 1.7 84 | 85 | ## Version 2.0.2 86 | 87 | - Added `settings.BETTY_CACHE_CROP_SEC` to allow configurable crop (and animated) cache times. Defaults to original `300` seconds. 88 | 89 | ## Version 2.0.1 90 | 91 | - Added S3 migration support: 92 | - `betty.cropper.storage.MigratedS3BotoStorage` allows parallel testing against filesystem + S3 storage by altering filesystem path to an S3 path at runtime. 93 | - New management command `change_storage_root` applies final storage name changes once testing completed. 94 | 95 | ## Version 2.0.0 96 | 97 | - Refactored storage system to use Django Storage API instead of raw filesystem calls, allowing configurable storage backends. Primarily tested with local filesystem and S3 backends. 98 | - Saving crops to local disk is now optional: 99 | - Added `BETTY_SAVE_CROPS_TO_DISK` boolean setting (default == `True`) to optionally disable writing crops to local disk 100 | - Added `BETTY_SAVE_CROPS_TO_DISK_ROOT` to specify root directory on local disk for crops (else will use `BETTY_IMAGE_ROOT` path) 101 | - Animated images (`/animated/original.{gif,jpg}`) are now created on-demand like crops via a new view. Previously these were created on demand and leaned on nginx to serve cached files from disk. This new approach plays better with generic storage API. 102 | - Tighten up URL regexes for `/image.js` and `/api/` path matching (were missing start + end markers). 103 | 104 | ### Upgrade Notes 105 | 106 | This new version can be dropped into an existing Betty environment, using same local disk filesystem as before, but may require a single settings change (see below). 107 | 108 | #### FileSystemStorage (Default) 109 | 110 | The default filesystem backend remains local disk storage (via `FileSystemStorage`). When upgrading, the `BETTY_IMAGE_ROOT` path must be located within the `MEDIA_ROOT` path, or you'll get a `SupiciousFileOperation` error. One option is to just set `MEDIA_ROOT = BETTY_IMAGE_ROOT`. **So at a minimum, to keep all behavior the same, just add this setting**: 111 | 112 | MEDIA_ROOT = BETTY_IMAGE_ROOT 113 | 114 | #### Alternate Storage Backend 115 | 116 | To use an alternate storage system, set the `DEFAULT_FILE_STORAGE` setting and configure per that storage's documentation. For example: 117 | 118 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto.S3BotoStorage' 119 | AWS_ACCESS_KEY_ID = 'MY AWS KEY' 120 | AWS_SECRET_ACCESS_KEY = 'XXX SECRET KEY XXX' 121 | AWS_STORAGE_BUCKET_NAME = 'mybucket' 122 | 123 | ## Version less than 2.0.0 124 | 125 | * These change notes have been lost to the mists of github * 126 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Betty Cropper Django server with support for 2 | # - uWSGI 3 | # - Postgres 4 | # - Memcached 5 | # - AWS S3 storage 6 | # - Sentry 7 | # 8 | FROM python:3.5 9 | MAINTAINER Onion Tech 10 | 11 | # Grab packages and then cleanup (to minimize image size) 12 | RUN apt-get update \ 13 | && apt-get upgrade -y \ 14 | && apt-get install -y \ 15 | libfreetype6-dev \ 16 | libjpeg-dev \ 17 | libtiff5-dev \ 18 | zlib1g-dev \ 19 | libblas-dev \ 20 | liblapack-dev \ 21 | libatlas-base-dev \ 22 | gfortran \ 23 | libmemcached-dev \ 24 | libpq-dev \ 25 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 26 | 27 | # Fixed settings we always want (and simplifies uWSGI invocation) 28 | ENV UWSGI_MODULE=betty.wsgi:application \ 29 | UWSGI_MASTER=1 30 | 31 | # Extra packages for Onion deployment 32 | RUN pip install "boto==2.39.0" \ 33 | "django-storages==1.4" \ 34 | "psycopg2==2.6.1" \ 35 | "pylibmc==1.5.1" \ 36 | "raven==4.2.1" \ 37 | "uwsgi>=2.0.11.1,<=2.1" 38 | 39 | # Setup app directory 40 | RUN mkdir -p /webapp 41 | WORKDIR /webapp 42 | 43 | COPY requirements/ /webapp/requirements/ 44 | 45 | RUN cd requirements && pip install -r common.txt \ 46 | -r dev.txt \ 47 | -r imgmin.txt 48 | 49 | # Add app as late as possibly (will always trigger cache miss and rebuild from here) 50 | ADD . /webapp 51 | 52 | RUN pip install . 53 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 The Onion 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements/*.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Betty Cropper 2 | 3 | [![Build Status](https://travis-ci.org/theonion/betty-cropper.svg?branch=master)](https://travis-ci.org/theonion/betty-cropper) 4 | [![Coverage Status](https://coveralls.io/repos/theonion/betty-cropper/badge.svg?branch=master)](https://coveralls.io/r/theonion/betty-cropper?branch=master) 5 | [![Latest Version](https://img.shields.io/pypi/v/betty-cropper.svg)](https://pypi.python.org/pypi/betty-cropper/) 6 | 7 | ## Get started developing: 8 | 9 | > git clone git@github.com:theonion/betty-cropper.git 10 | > cd betty-cropper 11 | > virtualenv . 12 | > source bin/activate 13 | > pip install -e . 14 | > pip install "file://$(pwd)#egg=betty-cropper[dev]" 15 | 16 | To run the tests: 17 | 18 | > py.test tests/ 19 | 20 | ## To run an instance of the server 21 | 22 | First, make sure that you've installed the development packages for JPEG, PNG, etc. 23 | 24 | > pip install betty-cropper 25 | > betty-cropper init 26 | 27 | Then edit the settings in `betty.conf.py` (if you want to use the dev server, you'll want to set DEBUG=True). 28 | 29 | > betty-cropper syncdb # Do the intial django sync 30 | > betty-cropper migrate # Migrate with south 31 | > betty-cropper create_token # Create an auth token, to use the API 32 | > betty-cropper runserver 33 | 34 | ## API 35 | 36 | Currently, authentication means sending an `X-Betty-Api-Key` header with a value of your public token. This will likely change to something more mature in future versions. 37 | 38 | `POST` an image (using the key "image") to /api/new, for example: 39 | 40 | > curl -H "X-Betty-Api-Key: YOUR_PUBLIC_TOKEN" --form "image=@Lenna.png" http://localhost:8000/api/new 41 | 42 | This should return JSON representing that image and its crops, for instance: 43 | 44 | { 45 | "name": "Lenna.png", 46 | "width": 512, 47 | "selections": { 48 | "16x9": {"y1": 400, "y0": 112, "x0": 0, "x1": 512, "source": "auto"}, 49 | "3x1": {"y1": 341, "y0": 171, "x0": 0, "x1": 512, "source": "auto"}, 50 | "1x1": {"y1": 512, "y0": 0, "x0": 0, "x1": 512, "source": "auto"}, 51 | "3x4": {"y1": 512, "y0": 0, "x0": 64, "x1": 448, "source": "auto"}, 52 | "2x1": {"y1": 384, "y0": 128, "x0": 0, "x1": 512, "source": "auto"}, 53 | "4x3": {"y1": 448, "y0": 64, "x0": 0, "x1": 512, "source": "auto"} 54 | }, 55 | "height": 512, 56 | "credit": null, 57 | "id": 1 58 | } 59 | 60 | You can get a cropped version of this image using a URL like: [http://localhost:8000/1/1x1/300.jpg](http://localhost:8000/1/1x1/300.jpg). 61 | 62 | To get the data form an image, send a `GET` request to /api/id, for example: 63 | 64 | > curl -H "X-Betty-Api-Key: YOUR_PUBLIC_TOKEN" http://localhost:8000/api/1 65 | 66 | To update the name or credit, use a `PATCH` on that same endpoint: 67 | 68 | > curl -H "X-Betty-Api-Key: YOUR_PUBLIC_TOKEN" \ 69 | -H "Content-Type: application/json" \ 70 | -XPATCH http://localhost:8000/api/1 \ 71 | -d '{"name":"Testing", "credit":"Some guy"}' 72 | 73 | To update the selections used for a crop, you can `POST` to /api/id/ratio, for example: 74 | 75 | > curl -H "X-Betty-Api-Key: YOUR_PUBLIC_TOKEN" \ 76 | -H "Content-Type: application/json" \ 77 | -XPOST http://localhost:8000/api/1/1x1 \ 78 | -d '{"x0":1,"y0":1,"x1":510,"y1":510}' 79 | 80 | `GET` /api/search, with an option "q" parameter in order to get a list of files matching that description. For example: 81 | 82 | > curl -H "X-Betty-Api-Key: YOUR_PUBLIC_TOKEN" -XGET http://localhost:8000/api/search?q=lenna 83 | 84 | 85 | ## Installation Notes 86 | 87 | ### OSX 88 | 89 | To avoid "*encoder/decoder zlib unavailble*" errors w/ Pillow library (OSX 10.11.2, Python 3.5.1), install zlib via homebrew before installing Pillow 90 | 91 | brew tap homebrew/dupes 92 | brew install zlib 93 | brew link --force zlib 94 | -------------------------------------------------------------------------------- /betty/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .celery import app as celery_app # noqa 4 | 5 | __version__ = "2.6.1" 6 | -------------------------------------------------------------------------------- /betty/authtoken/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/authtoken/__init__.py -------------------------------------------------------------------------------- /betty/authtoken/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/authtoken/management/__init__.py -------------------------------------------------------------------------------- /betty/authtoken/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/authtoken/management/commands/__init__.py -------------------------------------------------------------------------------- /betty/authtoken/management/commands/create_token.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from betty.authtoken.models import ApiToken 3 | 4 | 5 | class Command(BaseCommand): 6 | args = ' ' 7 | help = 'Creates an API Token pair' 8 | 9 | def handle(self, *args, **options): 10 | 11 | if len(args) == 2: 12 | token = ApiToken.objects.create( 13 | public_token=args[0], 14 | private_token=args[1], 15 | image_read_permission=True, 16 | image_change_permission=True, 17 | image_crop_permission=True, 18 | image_add_permsission=True, 19 | image_delete_permission=True 20 | ) 21 | elif len(args) == 0: 22 | token = ApiToken.objects.create_superuser() 23 | else: 24 | raise CommandError("Usage: django-admin.py create_token ") 25 | 26 | self.stdout.write("Public token: {0}\n".format(token.public_token)) 27 | self.stdout.write("Private token: {0}\n".format(token.private_token)) 28 | -------------------------------------------------------------------------------- /betty/authtoken/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import betty.authtoken.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='ApiToken', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('public_token', models.CharField(default=betty.authtoken.models.random_token, unique=True, max_length=255)), 19 | ('private_token', models.CharField(default=betty.authtoken.models.random_token, max_length=255)), 20 | ('image_read_permission', models.BooleanField(default=False)), 21 | ('image_change_permission', models.BooleanField(default=False)), 22 | ('image_crop_permission', models.BooleanField(default=False)), 23 | ('image_add_permsission', models.BooleanField(default=False)), 24 | ('image_delete_permission', models.BooleanField(default=False)), 25 | ], 26 | options={ 27 | }, 28 | bases=(models.Model,), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /betty/authtoken/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/authtoken/migrations/__init__.py -------------------------------------------------------------------------------- /betty/authtoken/models.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import os 3 | 4 | import django 5 | from django.db import models 6 | from django.db.models.manager import EmptyManager 7 | from django.contrib.auth.models import Group 8 | 9 | 10 | class BettyCropperUser(object): 11 | id = None 12 | pk = None 13 | username = '' 14 | is_staff = False 15 | is_active = False 16 | is_superuser = False 17 | if django.VERSION[1] < 6: 18 | _groups = EmptyManager() 19 | else: 20 | _groups = EmptyManager(Group) 21 | 22 | def __init__(self, permissions): 23 | self._permissions = permissions 24 | 25 | def __str__(self): 26 | return 'BettyCropperUser' 27 | 28 | def __eq__(self, other): 29 | return isinstance(other, self.__class__) 30 | 31 | def __ne__(self, other): 32 | return not self.__eq__(other) 33 | 34 | def __hash__(self): 35 | return 1 # instances always return the same hash value 36 | 37 | def save(self): 38 | raise NotImplementedError("Django doesn't provide DB representation for BettyCropperUser.") 39 | 40 | def delete(self): 41 | raise NotImplementedError("Django doesn't provide DB representation for BettyCropperUser.") 42 | 43 | def set_password(self, raw_password): 44 | raise NotImplementedError("Django doesn't provide DB representation for BettyCropperUser.") 45 | 46 | def check_password(self, raw_password): 47 | raise NotImplementedError("Django doesn't provide DB representation for BettyCropperUser.") 48 | 49 | def _get_groups(self): 50 | return self._groups 51 | groups = property(_get_groups) 52 | 53 | def _get_user_permissions(self): 54 | return self._permissions 55 | user_permissions = property(_get_user_permissions) 56 | 57 | def get_group_permissions(self, obj=None): 58 | return set() 59 | 60 | def get_all_permissions(self, obj=None): 61 | return self._permissions 62 | 63 | def has_perm(self, perm, obj=None): 64 | return perm in self._permissions 65 | 66 | def has_perms(self, perm_list, obj=None): 67 | for perm in perm_list: 68 | if not self.has_perm(perm, obj): 69 | return False 70 | return True 71 | 72 | def has_module_perms(self, module): 73 | return module == "server" and self._permissions 74 | 75 | def is_anonymous(self): 76 | return False 77 | 78 | def is_authenticated(self): 79 | return True 80 | 81 | 82 | def random_token(): 83 | return binascii.hexlify(os.urandom(20)) 84 | 85 | 86 | class ApiTokenManager(models.Manager): 87 | 88 | def create_superuser(self): 89 | return self.create( 90 | image_read_permission=True, 91 | image_change_permission=True, 92 | image_crop_permission=True, 93 | image_add_permsission=True, 94 | image_delete_permission=True 95 | ) 96 | 97 | def create_cropping_user(self): 98 | return self.create( 99 | image_read_permission=True, 100 | image_crop_permission=True 101 | ) 102 | 103 | def create_standard_user(self): 104 | return self.create( 105 | image_read_permission=True, 106 | image_change_permission=True, 107 | image_crop_permission=True, 108 | image_add_permsission=True, 109 | ) 110 | 111 | 112 | class ApiToken(models.Model): 113 | 114 | objects = ApiTokenManager() 115 | 116 | public_token = models.CharField(max_length=255, unique=True, default=random_token) 117 | private_token = models.CharField(max_length=255, default=random_token) 118 | 119 | image_read_permission = models.BooleanField(default=False) 120 | image_change_permission = models.BooleanField(default=False) 121 | image_crop_permission = models.BooleanField(default=False) 122 | image_add_permsission = models.BooleanField(default=False) 123 | image_delete_permission = models.BooleanField(default=False) 124 | 125 | def get_user(self): 126 | permissions = [] 127 | if self.image_read_permission: 128 | permissions.append("server.image_read") 129 | if self.image_change_permission: 130 | permissions.append("server.image_change") 131 | if self.image_crop_permission: 132 | permissions.append("server.image_crop") 133 | if self.image_add_permsission: 134 | permissions.append("server.image_add") 135 | if self.image_delete_permission: 136 | permissions.append("server.image_delete") 137 | return BettyCropperUser(permissions) 138 | -------------------------------------------------------------------------------- /betty/celery.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from betty.cropper.utils import runner 4 | from celery import Celery 5 | from django.conf import settings 6 | 7 | app = Celery('betty') 8 | 9 | try: 10 | runner.configure() 11 | except: 12 | pass 13 | 14 | app.config_from_object(settings) 15 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 16 | -------------------------------------------------------------------------------- /betty/conf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/conf/__init__.py -------------------------------------------------------------------------------- /betty/conf/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | try: 6 | from urlparse import urljoin 7 | except ImportError: 8 | from urllib.parse import urljoin 9 | 10 | from django.conf import settings as _settings 11 | 12 | from django.apps import AppConfig 13 | 14 | 15 | class BettyConfConfig(AppConfig): 16 | name = 'rock_n_roll' 17 | verbose_name = "Rock ’n’ roll" 18 | 19 | 20 | PACKAGE_DIR = os.path.dirname(os.path.dirname(__file__)) 21 | 22 | DEFAULTS = { 23 | "BETTY_IMAGE_ROOT": os.path.join(_settings.MEDIA_ROOT, "images"), 24 | "BETTY_IMAGE_URL": urljoin(_settings.MEDIA_URL, "images/"), 25 | "BETTY_IMAGE_URL_USE_REQUEST_HOST": False, 26 | "BETTY_RATIOS": ("1x1", "2x1", "3x1", "3x4", "4x3", "16x9"), 27 | "BETTY_WIDTHS": [], 28 | "BETTY_CLIENT_ONLY_WIDTHS": [], 29 | "BETTY_PLACEHOLDER": _settings.DEBUG, 30 | "BETTY_PLACEHOLDER_COLORS": ( 31 | (153, 153, 51), 32 | (102, 153, 51), 33 | (51, 153, 51), 34 | (153, 51, 51), 35 | (194, 133, 71), 36 | (51, 153, 102), 37 | (153, 51, 102), 38 | (71, 133, 194), 39 | (51, 153, 153), 40 | (153, 51, 153) 41 | ), 42 | "BETTY_PLACEHOLDER_FONT": os.path.join(PACKAGE_DIR, "cropper/font/OpenSans-Semibold.ttf"), 43 | "BETTY_PUBLIC_TOKEN": None, 44 | "BETTY_PRIVATE_TOKEN": None, 45 | "BETTY_CACHE_FLUSHER": None, 46 | "BETTY_DEFAULT_IMAGE": None, 47 | "BETTY_MAX_WIDTH": 3200, 48 | "BETTY_DEFAULT_JPEG_QUALITY": 80, 49 | "BETTY_JPEG_MAX_ERROR": 3.5, 50 | "BETTY_JPEG_QUALITY_RANGE": None, 51 | "BETTY_SAVE_CROPS_TO_DISK": True, # On by default (per legacy behavior) 52 | "BETTY_SAVE_CROPS_TO_DISK_ROOT": None, # If not set, will use BETTY_IMAGE_ROOT 53 | "BETTY_CACHE_CROP_SEC": 300, 54 | "BETTY_CACHE_CROP_NON_BREAKPOINT_SEC": None, # If not set, will use BETTY_CACHE_CROP_SEC 55 | "BETTY_CACHE_IMAGEJS_SEC": 300, 56 | "BETTY_CACHE_STORAGE_SEC": 3600, 57 | } 58 | 59 | 60 | class BettySettings(object): 61 | ''' 62 | Lazy Django settings wrapper for Betty Cropper 63 | ''' 64 | def __init__(self, wrapped_settings): 65 | self.wrapped_settings = wrapped_settings 66 | 67 | def __getattr__(self, name): 68 | if hasattr(self.wrapped_settings, name): 69 | return getattr(self.wrapped_settings, name) 70 | elif name in DEFAULTS: 71 | return DEFAULTS[name] 72 | else: 73 | raise AttributeError("'{0}' setting not found".format(name)) 74 | 75 | settings = BettySettings(_settings) 76 | -------------------------------------------------------------------------------- /betty/conf/server.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | import os.path 4 | import socket 5 | import sys 6 | 7 | import django 8 | from django.conf.global_settings import * # NOQA 9 | 10 | socket.setdefaulttimeout(5) 11 | 12 | DEBUG = False 13 | TEMPLATE_DEBUG = False 14 | 15 | ADMINS = () 16 | 17 | INTERNAL_IPS = ('127.0.0.1',) 18 | 19 | MANAGERS = ADMINS 20 | 21 | APPEND_SLASH = False 22 | 23 | PROJECT_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), os.pardir)) 24 | 25 | sys.path.insert(0, os.path.normpath(os.path.join(PROJECT_ROOT, os.pardir))) 26 | 27 | CACHES = { 28 | 'default': { 29 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 30 | } 31 | } 32 | 33 | DATABASES = { 34 | 'default': { 35 | 'ENGINE': 'django.db.backends.sqlite3', 36 | 'NAME': 'betty.db', 37 | 'USER': '', 38 | 'PASSWORD': '', 39 | 'HOST': '', 40 | 'PORT': '', 41 | } 42 | } 43 | 44 | EMAIL_SUBJECT_PREFIX = '[Betty Cropper] ' 45 | 46 | # Local time zone for this installation. Choices can be found here: 47 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 48 | # although not all choices may be available on all operating systems. 49 | # On Unix systems, a value of None will cause Django to use the same 50 | # timezone as the operating system. 51 | # If running in a Windows environment this must be set to the same as your 52 | # system time zone. 53 | TIME_ZONE = 'UTC' 54 | 55 | # Language code for this installation. All choices can be found here: 56 | # http://www.i18nguy.com/unicode/language-identifiers.html 57 | LANGUAGE_CODE = 'en-us' 58 | 59 | SITE_ID = 1 60 | 61 | # If you set this to False, Django will make some optimizations so as not 62 | # to load the internationalization machinery. 63 | USE_I18N = True 64 | 65 | # If you set this to False, Django will not format dates, numbers and 66 | # calendars according to the current locale 67 | USE_L10N = True 68 | 69 | USE_TZ = True 70 | 71 | # Make this unique, and don't share it with anybody. 72 | KEY_COMPONENT = ")*)&8a36)6%74e@-ne5(-!8a(vv#tkv()eyg&@0=zd^pl!7=y@".encode("utf-8") 73 | SECRET_KEY = hashlib.md5(socket.gethostname().encode("utf-8") + KEY_COMPONENT).hexdigest() 74 | 75 | # List of callables that know how to import templates from various sources. 76 | TEMPLATE_LOADERS = ( 77 | 'django.template.loaders.filesystem.Loader', 78 | 'django.template.loaders.app_directories.Loader', 79 | ) 80 | 81 | MIDDLEWARE_CLASSES = ( 82 | 'django.middleware.common.CommonMiddleware', 83 | 'django.contrib.sessions.middleware.SessionMiddleware', 84 | 'django.middleware.csrf.CsrfViewMiddleware', 85 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 86 | 'django.contrib.messages.middleware.MessageMiddleware', 87 | ) 88 | 89 | ROOT_URLCONF = 'betty.conf.urls' 90 | 91 | TEMPLATE_DIRS = ( 92 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 93 | # Always use forward slashes, even on Windows. 94 | # Don't forget to use absolute paths, not relative paths. 95 | os.path.join(PROJECT_ROOT, 'templates'), 96 | ) 97 | 98 | INSTALLED_APPS = ( 99 | 'django.contrib.auth', 100 | 'django.contrib.contenttypes', 101 | 'django.contrib.messages', 102 | 'django.contrib.sessions', 103 | 'django.contrib.staticfiles', 104 | 105 | 'betty.cropper', 106 | 'betty.authtoken', 107 | # 'betty.image_browser', 108 | ) 109 | 110 | # Only use south with django < 1.7 111 | if django.VERSION < (1, 7): 112 | INSTALLED_APPS = INSTALLED_APPS + ('south',) 113 | 114 | 115 | CELERY_IGNORE_RESULT = True 116 | CELERY_ALWAYS_EAGER = True 117 | CELERY_EAGER_PROPAGATES_EXCEPTIONS = True 118 | BROKER_URL = 'amqp://guest@127.0.0.1:5672//' 119 | 120 | BETTY_STANDALONE_SERVER = True 121 | 122 | LOGIN_URL = "/login/" 123 | LOGIN_REDIRECT_URL = "/" 124 | 125 | STATIC_ROOT = os.path.realpath(os.path.join(PROJECT_ROOT, 'static')) 126 | STATIC_URL = '/static/' 127 | 128 | STATICFILES_FINDERS = ( 129 | "django.contrib.staticfiles.finders.FileSystemFinder", 130 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 131 | ) 132 | 133 | # Doing this because we always want to upload to temp files. 134 | FILE_UPLOAD_HANDLERS = ("django.core.files.uploadhandler.TemporaryFileUploadHandler",) 135 | -------------------------------------------------------------------------------- /betty/conf/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urlparse import urlparse 3 | except ImportError: 4 | from urllib.parse import urlparse 5 | 6 | from .app import settings 7 | from django.conf.urls.static import static 8 | 9 | try: 10 | from django.conf.urls import include, patterns, url 11 | except ImportError: 12 | # django < 1.5 compat 13 | from django.conf.urls.defaults import include, patterns, url # noqa 14 | 15 | # TODO: fix up this awful, awful shit. 16 | image_path = urlparse(settings.BETTY_IMAGE_URL).path 17 | if image_path.startswith("/"): 18 | image_path = image_path[1:] 19 | 20 | if image_path != "" and not image_path.endswith("/"): 21 | image_path += "/" 22 | 23 | urlpatterns = patterns('', 24 | url(r'^{0}'.format(image_path), include("betty.cropper.urls")), # noqa 25 | url(r'browser/', include("betty.image_browser.urls")), 26 | url(r'login/', "django.contrib.auth.views.login") 27 | ) 28 | 29 | if settings.DEBUG: 30 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 31 | -------------------------------------------------------------------------------- /betty/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/contrib/__init__.py -------------------------------------------------------------------------------- /betty/contrib/cacheflush/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/contrib/cacheflush/__init__.py -------------------------------------------------------------------------------- /betty/contrib/cacheflush/cachemaster.py: -------------------------------------------------------------------------------- 1 | # Sample BETTY_CACHE_FLUSHER integration using Onion's CacheMaster service 2 | # 3 | try: 4 | from urllib.parse import urljoin 5 | except ImportError: 6 | from urlparse import urljoin 7 | 8 | import requests 9 | 10 | from betty.conf.app import settings 11 | 12 | logger = __import__('logging').getLogger(__name__) 13 | 14 | 15 | def flush(paths): 16 | 17 | if not hasattr(settings, 'CACHEMASTER_URLS') or not isinstance(settings.CACHEMASTER_URLS, list): 18 | raise KeyError('Invalid/missing setting: "CACHEMASTER_URLS" - list of string URLS') 19 | 20 | urls = [urljoin(settings.BETTY_IMAGE_URL, path) for path in paths] 21 | 22 | # Try multiple URLS (for redundancy) 23 | for idx, cm_url in enumerate(settings.CACHEMASTER_URLS, start=1): 24 | try: 25 | resp = requests.post(cm_url, json=dict(urls=urls)) 26 | if resp.ok: 27 | return resp 28 | else: 29 | logger.error('CacheMaster flush failed (%s/%s): %s %s %s %s', 30 | idx, 31 | len(settings.CACHEMASTER_URLS), 32 | cm_url, 33 | urls, 34 | resp.status_code, 35 | resp.reason) 36 | except requests.RequestException: 37 | logger.exception('CacheMaster flush failed (%s/%s): %s %s', 38 | idx, 39 | len(settings.CACHEMASTER_URLS), 40 | cm_url, 41 | urls) 42 | -------------------------------------------------------------------------------- /betty/cropper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/__init__.py -------------------------------------------------------------------------------- /betty/cropper/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/api/__init__.py -------------------------------------------------------------------------------- /betty/cropper/api/decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import wraps 3 | 4 | from django.conf import settings 5 | from django.http import HttpResponseForbidden 6 | from django.utils.decorators import available_attrs 7 | 8 | from betty.authtoken.models import ApiToken 9 | 10 | 11 | def forbidden(): 12 | response_text = json.dumps({'message': 'Not authorized'}) 13 | return HttpResponseForbidden(response_text, content_type="application/json") 14 | 15 | 16 | def betty_token_auth(permissions): 17 | """ 18 | Decorator to make a view only accept particular request methods. Usage:: 19 | 20 | @require_http_methods(["GET", "POST"]) 21 | def my_view(request): 22 | # I can assume now that only GET or POST requests make it this far 23 | # ... 24 | 25 | Note that request methods should be in uppercase. 26 | """ 27 | def decorator(func): 28 | @wraps(func, assigned=available_attrs(func)) 29 | def inner(request, *args, **kwargs): 30 | if "betty.authtoken" in settings.INSTALLED_APPS: 31 | if request.user.is_anonymous(): 32 | if "HTTP_X_BETTY_API_KEY" not in request.META: 33 | return forbidden() 34 | 35 | api_key = request.META["HTTP_X_BETTY_API_KEY"] 36 | try: 37 | token = ApiToken.objects.get(public_token=api_key) 38 | except ApiToken.DoesNotExist: 39 | return forbidden() 40 | 41 | request.user = token.get_user() 42 | if not request.user.has_perms(permissions): 43 | return forbidden() 44 | 45 | return func(request, *args, **kwargs) 46 | return inner 47 | return decorator 48 | -------------------------------------------------------------------------------- /betty/cropper/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | urlpatterns = patterns('betty.cropper.api.views', 4 | url(r'^new$', 'new'), # noqa 5 | url(r'^search$', 'search'), 6 | url(r'^(?P\d+)/(?P[a-z0-9]+)$', 'update_selection'), 7 | url(r'^(?P\d+)$', 'detail'), 8 | ) 9 | -------------------------------------------------------------------------------- /betty/cropper/api/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.core.cache import cache 4 | from django.http import ( 5 | HttpResponse, 6 | HttpResponseNotAllowed, 7 | HttpResponseBadRequest, 8 | HttpResponseNotFound 9 | ) 10 | from django.views.decorators.csrf import csrf_exempt 11 | from django.views.decorators.cache import never_cache 12 | 13 | from betty.conf.app import settings 14 | from .decorators import betty_token_auth 15 | from betty.cropper.models import Image 16 | 17 | 18 | ACC_HEADERS = { 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 21 | 'Access-Control-Max-Age': 1000, 22 | 'Access-Control-Allow-Headers': '*' 23 | } 24 | 25 | 26 | def crossdomain(origin="*", methods=[], headers=["X-Betty-Api-Key", "Content-Type", "X-CSRFToken"]): 27 | 28 | def _method_wrapper(func): 29 | 30 | def _crossdomain_wrapper(request, *args, **kwargs): 31 | if request.method != "OPTIONS": 32 | response = func(request, *args, **kwargs) 33 | else: 34 | response = HttpResponse() 35 | response["Access-Control-Allow-Origin"] = "*" 36 | if methods: 37 | if request.method not in methods: 38 | return HttpResponseNotAllowed(methods) 39 | response["Access-Control-Allow-Methods"] = ", ".join(methods) 40 | if headers: 41 | response["Access-Control-Allow-Headers"] = ", ".join(headers) 42 | return response 43 | 44 | return _crossdomain_wrapper 45 | 46 | return _method_wrapper 47 | 48 | 49 | @never_cache 50 | @csrf_exempt 51 | @crossdomain(methods=['POST', 'OPTIONS']) 52 | @betty_token_auth(["server.image_add"]) 53 | def new(request): 54 | image_file = request.FILES.get("image") 55 | if image_file is None: 56 | return HttpResponseBadRequest(json.dumps({'message': 'No image'})) 57 | 58 | image = Image.objects.create_from_path( 59 | image_file.temporary_file_path(), 60 | filename=image_file.name, 61 | name=request.POST.get("name"), 62 | credit=request.POST.get("credit") 63 | ) 64 | 65 | return HttpResponse(json.dumps(image.to_native()), content_type="application/json") 66 | 67 | 68 | @never_cache 69 | @csrf_exempt 70 | @crossdomain(methods=['POST', 'OPTIONS']) 71 | @betty_token_auth(["server.image_crop"]) 72 | def update_selection(request, image_id, ratio_slug): 73 | 74 | try: 75 | image = Image.objects.get(id=image_id) 76 | except Image.DoesNotExist: 77 | message = json.dumps({"message": "No such image!"}) 78 | return HttpResponseNotFound(message, content_type="application/json") 79 | 80 | try: 81 | request_json = json.loads(request.body.decode("utf-8")) 82 | except Exception: 83 | message = json.dumps({"message": "Bad JSON"}) 84 | return HttpResponseBadRequest(message, content_type="application/json") 85 | try: 86 | selection = { 87 | "x0": int(request_json["x0"]), 88 | "y0": int(request_json["y0"]), 89 | "x1": int(request_json["x1"]), 90 | "y1": int(request_json["y1"]), 91 | } 92 | except (KeyError, ValueError): 93 | message = json.dumps({"message": "Bad selection"}) 94 | return HttpResponseBadRequest(message, content_type="application/json") 95 | 96 | if ratio_slug not in settings.BETTY_RATIOS: 97 | message = json.dumps({"message": "No such ratio"}) 98 | return HttpResponseBadRequest(message, content_type="application/json") 99 | 100 | if image.selections is None: 101 | image.selections = {} 102 | 103 | image.selections[ratio_slug] = selection 104 | image.save() 105 | cache.delete(image.cache_key()) 106 | 107 | image.clear_crops(ratios=[ratio_slug]) 108 | 109 | return HttpResponse(json.dumps(image.to_native()), content_type="application/json") 110 | 111 | 112 | @never_cache 113 | @csrf_exempt 114 | @crossdomain(methods=['GET', 'OPTIONS']) 115 | @betty_token_auth(["server.image_read"]) 116 | def search(request): 117 | 118 | results = [] 119 | query = request.GET.get("q") 120 | if query: 121 | for image in Image.objects.filter(name__icontains=query)[:20]: 122 | results.append(image.to_native()) 123 | else: 124 | for image in Image.objects.all()[:20]: 125 | results.append(image.to_native()) 126 | 127 | return HttpResponse(json.dumps({"results": results}), content_type="application/json") 128 | 129 | 130 | @never_cache 131 | @csrf_exempt 132 | @crossdomain(methods=["GET", "PATCH", "OPTIONS", "DELETE"]) 133 | def detail(request, image_id): 134 | 135 | @betty_token_auth(["server.image_delete"]) 136 | def delete(request, image_id): 137 | 138 | try: 139 | image = Image.objects.get(id=image_id) 140 | except Image.DoesNotExist: 141 | message = json.dumps({"message": "No such image!"}) 142 | return HttpResponseNotFound(message, content_type="application/json") 143 | 144 | cache_key = image.cache_key() 145 | image.delete() 146 | cache.delete(cache_key) 147 | 148 | return HttpResponse(json.dumps({"message": "OK"}), content_type="application/json") 149 | 150 | @betty_token_auth(["server.image_change"]) 151 | def patch(request, image_id): 152 | 153 | try: 154 | image = Image.objects.get(id=image_id) 155 | except Image.DoesNotExist: 156 | message = json.dumps({"message": "No such image!"}) 157 | return HttpResponseNotFound(message, content_type="application/json") 158 | 159 | try: 160 | request_json = json.loads(request.body.decode("utf-8")) 161 | except Exception: 162 | message = json.dumps({"message": "Bad Request"}) 163 | return HttpResponseBadRequest(message, content_type="application/json") 164 | 165 | for field in ("name", "credit", "selections"): 166 | if field in request_json: 167 | setattr(image, field, request_json[field]) 168 | image.save() 169 | cache.delete(image.cache_key()) 170 | 171 | return HttpResponse(json.dumps(image.to_native()), content_type="application/json") 172 | 173 | @betty_token_auth(["server.image_read"]) 174 | def get(request, image_id): 175 | cache_key = "image-{}".format(image_id) 176 | data = cache.get(cache_key) 177 | if data is None: 178 | try: 179 | image = Image.objects.get(id=image_id) 180 | except Image.DoesNotExist: 181 | message = json.dumps({"message": "No such image!"}) 182 | return HttpResponseNotFound(message, content_type="application/json") 183 | data = image.to_native() 184 | cache.set(cache_key, data, 60 * 60) 185 | 186 | return HttpResponse(json.dumps(data), content_type="application/json") 187 | 188 | if request.method == "DELETE": 189 | return delete(request, image_id) 190 | elif request.method == "PATCH": 191 | return patch(request, image_id) 192 | return get(request, image_id) 193 | -------------------------------------------------------------------------------- /betty/cropper/dssim.py: -------------------------------------------------------------------------------- 1 | try: 2 | import numpy as np 3 | import scipy.ndimage 4 | except ImportError: 5 | pass 6 | 7 | from betty.conf.app import settings 8 | import io 9 | import math 10 | 11 | from PIL import Image 12 | 13 | 14 | MIN_UNIQUE_COLORS = 4096 15 | COLOR_DENSITY_RATIO = 0.11 16 | 17 | QUALITY_IN_MIN = 82 18 | 19 | ERROR_THRESHOLD = 1.3 20 | 21 | ERROR_THRESHOLD_INACCURACY = 0.01 22 | 23 | 24 | def compute_ssim(im1, im2, l=255): 25 | 26 | # k1,k2 & c1,c2 depend on L (width of color map) 27 | k_1 = 0.01 28 | c_1 = (k_1 * l) ** 2 29 | k_2 = 0.03 30 | c_2 = (k_2 * l) ** 2 31 | 32 | window = np.ones((8, 8)) / 64.0 33 | 34 | # Convert image matrices to double precision (like in the Matlab version) 35 | im1 = im1.astype(np.float) 36 | im2 = im2.astype(np.float) 37 | 38 | # Means obtained by Gaussian filtering of inputs 39 | mu_1 = scipy.ndimage.filters.convolve(im1, window) 40 | mu_2 = scipy.ndimage.filters.convolve(im2, window) 41 | 42 | # Squares of means 43 | mu_1_sq = mu_1 ** 2 44 | mu_2_sq = mu_2 ** 2 45 | mu_1_mu_2 = mu_1 * mu_2 46 | 47 | # Squares of input matrices 48 | im1_sq = im1 ** 2 49 | im2_sq = im2 ** 2 50 | im12 = im1 * im2 51 | 52 | # Variances obtained by Gaussian filtering of inputs' squares 53 | sigma_1_sq = scipy.ndimage.filters.convolve(im1_sq, window) 54 | sigma_2_sq = scipy.ndimage.filters.convolve(im2_sq, window) 55 | 56 | # Covariance 57 | sigma_12 = scipy.ndimage.filters.convolve(im12, window) 58 | 59 | # Centered squares of variances 60 | sigma_1_sq -= mu_1_sq 61 | sigma_2_sq -= mu_2_sq 62 | sigma_12 -= mu_1_mu_2 63 | 64 | if (c_1 > 0) & (c_2 > 0): 65 | ssim_map = (((2 * mu_1_mu_2 + c_1) * (2 * sigma_12 + c_2)) / 66 | ((mu_1_sq + mu_2_sq + c_1) * (sigma_1_sq + sigma_2_sq + c_2))) 67 | else: 68 | numerator1 = 2 * mu_1_mu_2 + c_1 69 | numerator2 = 2 * sigma_12 + c_2 70 | 71 | denominator1 = mu_1_sq + mu_2_sq + c_1 72 | denominator2 = sigma_1_sq + sigma_2_sq + c_2 73 | 74 | ssim_map = np.ones(mu_1.size) 75 | 76 | index = (denominator1 * denominator2 > 0) 77 | 78 | ssim_map[index] = ((numerator1[index] * numerator2[index]) / 79 | (denominator1[index] * denominator2[index])) 80 | index = (denominator1 != 0) & (denominator2 == 0) 81 | ssim_map[index] = numerator1[index] / denominator1[index] 82 | 83 | # return MSSIM 84 | index = np.mean(ssim_map) 85 | 86 | return index 87 | 88 | 89 | def unique_colors(img): 90 | # For RGB, we need to get unique "rows" basically, as the color dimesion is an array. 91 | # This is taken from: http://stackoverflow.com/a/16973510 92 | color_view = np.ascontiguousarray(img).view(np.dtype((np.void, 93 | img.dtype.itemsize * img.shape[2]))) 94 | unique = np.unique(color_view) 95 | return unique.size 96 | 97 | 98 | def color_density(img): 99 | area = img.shape[0] * img.shape[1] 100 | density = unique_colors(img) / float(area) 101 | return density 102 | 103 | 104 | def enough_colors(img): 105 | return True 106 | if unique_colors(img) < MIN_UNIQUE_COLORS: 107 | return False 108 | 109 | # Someday, check if the image is greyscale... 110 | return True 111 | 112 | 113 | def get_distortion(one, two): 114 | # This computes the "DSSIM" of the images, using the SSIM of each channel 115 | 116 | ssims = [] 117 | 118 | for channel in range(one.shape[2]): 119 | one_channeled = np.ascontiguousarray(one[:, :, channel]) 120 | two_channeled = np.ascontiguousarray(two[:, :, channel]) 121 | 122 | ssim = compute_ssim(one_channeled, two_channeled) 123 | 124 | ssims.append(ssim) 125 | 126 | return (1 / np.mean(ssims) - 1) * 20 127 | 128 | 129 | def detect_optimal_quality(image_buffer, width=None, verbose=False): 130 | """Returns the optimal quality for a given image, at a given width""" 131 | 132 | # Open the image... 133 | pil_original = Image.open(image_buffer) 134 | icc_profile = pil_original.info.get("icc_profile") 135 | 136 | if pil_original.format != "JPEG": 137 | # Uhoh, this isn't a JPEG, let's convert it to one. 138 | pillow_kwargs = { 139 | "format": "jpeg", 140 | "quality": 100, 141 | "subsampling": 2 142 | } 143 | if icc_profile: 144 | pillow_kwargs["icc_profile"] = icc_profile 145 | tmp = io.BytesIO() 146 | pil_original.save(tmp, **pillow_kwargs) 147 | tmp.seek(0) 148 | pil_original = Image.open(tmp) 149 | 150 | if width: 151 | height = int(math.ceil((pil_original.size[1] * width) / float(pil_original.size[0]))) 152 | pil_original = pil_original.resize((width, height), resample=Image.ANTIALIAS) 153 | 154 | np_original = np.asarray(pil_original) 155 | original_density = color_density(np_original) 156 | 157 | # Check if there are enough colors (assuming RGB for the moment) 158 | if not enough_colors(np_original): 159 | return None 160 | 161 | # TODO: Check if the quality is lower than we'd want... (probably impossible) 162 | qmin = settings.BETTY_JPEG_QUALITY_RANGE[0] 163 | qmax = settings.BETTY_JPEG_QUALITY_RANGE[1] 164 | 165 | # Do a binary search of image quality... 166 | while qmax > qmin + 1: 167 | quality = int(round((qmax + qmin) / 2.0)) 168 | 169 | tmp = io.BytesIO() 170 | pillow_kwargs = { 171 | "format": "jpeg", 172 | "quality": quality, 173 | "subsampling": 2 174 | } 175 | 176 | if icc_profile: 177 | pillow_kwargs["icc_profile"] = icc_profile 178 | pil_original.save(tmp, **pillow_kwargs) 179 | tmp.seek(0) 180 | pil_compressed = Image.open(tmp) 181 | 182 | np_compressed = np.asarray(pil_compressed) 183 | density_ratio = abs(color_density(np_compressed) - original_density) / original_density 184 | 185 | error = get_distortion(np_original, np_compressed) 186 | 187 | if density_ratio > COLOR_DENSITY_RATIO: 188 | error *= 1.25 + density_ratio 189 | 190 | if error > ERROR_THRESHOLD: 191 | qmin = quality 192 | else: 193 | qmax = quality 194 | 195 | if verbose: 196 | print("{:.2f}/{:.2f}@{}".format(error, density_ratio, quality)) 197 | 198 | if abs(error - ERROR_THRESHOLD) < ERROR_THRESHOLD * ERROR_THRESHOLD_INACCURACY: 199 | # Close enough! 200 | qmax = quality 201 | break 202 | 203 | return qmax 204 | -------------------------------------------------------------------------------- /betty/cropper/flush.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from betty.conf.app import settings 4 | 5 | 6 | def get_cache_flusher(): 7 | if settings.BETTY_CACHE_FLUSHER: 8 | if callable(settings.BETTY_CACHE_FLUSHER): 9 | return settings.BETTY_CACHE_FLUSHER 10 | else: 11 | return import_string(settings.BETTY_CACHE_FLUSHER) 12 | -------------------------------------------------------------------------------- /betty/cropper/font/OpenSans-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/font/OpenSans-Semibold.ttf -------------------------------------------------------------------------------- /betty/cropper/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/management/__init__.py -------------------------------------------------------------------------------- /betty/cropper/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/management/commands/__init__.py -------------------------------------------------------------------------------- /betty/cropper/management/commands/change_storage_root.py: -------------------------------------------------------------------------------- 1 | from optparse import make_option 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | from betty.cropper.models import Image 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Change root path for Image file fields' 10 | 11 | # This needs to run on Django 1.7 12 | option_list = BaseCommand.option_list + ( 13 | make_option('--check', 14 | action='store_true', 15 | dest='check', 16 | default=False, 17 | help='Dry-run (read-only) check mode'), 18 | make_option('--old', 19 | dest='old', 20 | help='Old root path (ex: /var/betty-cropper/)'), 21 | make_option('--new', 22 | dest='new', 23 | help='New root path (ex: /testing/)'), 24 | ) 25 | 26 | # This only works on Django 1.8+ 27 | # def add_arguments(self, parser): 28 | # parser.add_argument('--check', action='store_true', help='') 29 | # parser.add_argument('--old', required=True, help='Old root path (ex: /var/betty-cropper)') 30 | # parser.add_argument('--new', required=True, help='New root path (ex: /testing/)') 31 | 32 | def handle(self, *args, **options): 33 | 34 | if not options['old']: 35 | raise Exception('Old root not provided') 36 | 37 | if not options['new']: 38 | raise Exception('New root not provided') 39 | 40 | # Sanity check: Make sure same separator ending 41 | assert options['old'].endswith('/') == options['new'].endswith('/') 42 | 43 | self.stdout.write('Checking {} images...'.format(Image.objects.count())) 44 | 45 | for image in Image.objects.iterator(): 46 | 47 | for field in [image.source, 48 | image.optimized]: 49 | if field: 50 | if (field.name.startswith(options['old']) and 51 | not field.name.startswith(options['new'])): # Prevent re-migrating 52 | 53 | name = (options['new'] + field.name[len(options['old']):]) 54 | self.stdout.write(u'{}Update name: {} --> {}'.format( 55 | '[CHECK] ' if options['check'] else '', 56 | field.name, 57 | name)) 58 | 59 | if not options['check']: 60 | field.name = name 61 | image.save() 62 | -------------------------------------------------------------------------------- /betty/cropper/management/commands/make_disk_storage_paths_absolute.py: -------------------------------------------------------------------------------- 1 | from optparse import make_option 2 | import os.path 3 | 4 | from betty.conf.app import settings 5 | from django.core.management.base import BaseCommand 6 | 7 | from betty.cropper.models import Image 8 | 9 | 10 | class Command(BaseCommand): 11 | help = 'Convert disk storage relative paths to absolute' 12 | 13 | # This needs to run on Django 1.7 14 | option_list = BaseCommand.option_list + ( 15 | make_option('--check', 16 | action='store_true', 17 | dest='check', 18 | default=False, 19 | help='Dry-run (read-only) check mode'), 20 | make_option('--limit', 21 | type=int, 22 | dest='limit', 23 | help='Maximum number of images to process'), 24 | ) 25 | 26 | def handle(self, *args, **options): 27 | 28 | for idx, image in enumerate(Image.objects.order_by('pk').iterator()): 29 | 30 | if options['limit'] and idx >= options['limit']: 31 | self.stdout.write('Early exit (limit %s reached)'.format(options['limit'])) 32 | break 33 | 34 | for field in [image.source, 35 | image.optimized]: 36 | 37 | if field.name: 38 | if not field.name.startswith(settings.MEDIA_ROOT): 39 | 40 | path = os.path.join(settings.MEDIA_ROOT, field.name) 41 | 42 | self.stdout.write(u'{}{}\t{} --> {}'.format( 43 | '[CHECK] ' if options['check'] else '', 44 | image.id, 45 | field.name, 46 | path)) 47 | 48 | # Sanity checks 49 | assert os.path.exists(path) 50 | assert path.startswith(settings.MEDIA_ROOT) 51 | assert path.endswith(field.name) 52 | assert '//' not in path, "Guard against weird path joins" 53 | 54 | if not options['check']: 55 | field.name = path 56 | image.save() 57 | else: 58 | self.stdout.write('SKIP: {} {}'.format(image.id, field.name)) 59 | -------------------------------------------------------------------------------- /betty/cropper/management/commands/optimize_images.py: -------------------------------------------------------------------------------- 1 | 2 | from django.core.management.base import BaseCommand 3 | 4 | from betty.cropper.models import Image 5 | from betty.cropper.tasks import search_image_quality, optimize_image 6 | 7 | 8 | class Command(BaseCommand): 9 | help = 'Creates optimal image files, and figures out the best JPEG quality level for each image' 10 | 11 | def handle(self, *args, **options): 12 | for image in Image.objects.iterator(): 13 | if not image.source.name: 14 | continue 15 | 16 | if not image.optimized.name: 17 | optimize_image.apply(args=(image.id,)) 18 | 19 | if image.jpeg_quality is None: 20 | search_image_quality.apply(args=(image.id,)) 21 | -------------------------------------------------------------------------------- /betty/cropper/middleware.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AnonymousUser 2 | from .auth import ApiToken 3 | 4 | 5 | class BettyApiKeyMiddleware(object): 6 | 7 | def process_request(self, request): 8 | if "HTTP_X_BETTY_API_KEY" in request.META: 9 | api_key = request.META["HTTP_X_BETTY_API_KEY"] 10 | try: 11 | token = ApiToken.objects.get(public_token=api_key) 12 | except ApiToken.DoesNotExist: 13 | request.user = AnonymousUser() 14 | else: 15 | request.user = token.get_user() 16 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import django.core.files.storage 6 | import betty.cropper.models 7 | import jsonfield.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Image', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('name', models.CharField(max_length=255)), 21 | ('credit', models.CharField(max_length=120, null=True, blank=True)), 22 | ('source', models.FileField(storage=django.core.files.storage.FileSystemStorage(base_url='/', location='/private/var/folders/_3/mlzyzxsj5lb617stmlnstkgr0000gp/T/virtualenv.xyQsXv9C/images'), max_length=255, null=True, upload_to=betty.cropper.models.source_upload_to, blank=True)), 23 | ('optimized', models.FileField(storage=django.core.files.storage.FileSystemStorage(base_url='/', location='/private/var/folders/_3/mlzyzxsj5lb617stmlnstkgr0000gp/T/virtualenv.xyQsXv9C/images'), max_length=255, null=True, upload_to=betty.cropper.models.optimized_upload_to, blank=True)), 24 | ('height', models.IntegerField(null=True, blank=True)), 25 | ('width', models.IntegerField(null=True, blank=True)), 26 | ('selections', jsonfield.fields.JSONField(null=True, blank=True)), 27 | ('jpeg_quality', models.IntegerField(null=True, blank=True)), 28 | ('animated', models.BooleanField(default=False)), 29 | ], 30 | options={ 31 | 'permissions': (('read', 'Can search images, and see the detail data'), ('crop', 'Can crop images')), 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0001_squashed_0004_auto_20160317_1706.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import jsonfield.fields 6 | import betty.cropper.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | replaces = [('cropper', '0001_initial'), ('cropper', '0002_auto_20141203_2115'), ('cropper', '0003_image_jpeg_qualities'), ('cropper', '0004_auto_20160317_1706')] 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Image', 19 | fields=[ 20 | ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), 21 | ('name', models.CharField(max_length=255)), 22 | ('credit', models.CharField(null=True, blank=True, max_length=120)), 23 | ('source', models.FileField(upload_to=betty.cropper.models.source_upload_to, null=True, blank=True, max_length=255)), 24 | ('optimized', models.FileField(upload_to=betty.cropper.models.optimized_upload_to, null=True, blank=True, max_length=255)), 25 | ('height', models.IntegerField(null=True, blank=True)), 26 | ('width', models.IntegerField(null=True, blank=True)), 27 | ('selections', jsonfield.fields.JSONField(null=True, blank=True)), 28 | ('jpeg_quality', models.IntegerField(null=True, blank=True)), 29 | ('animated', models.BooleanField(default=False)), 30 | ('jpeg_quality_settings', jsonfield.fields.JSONField(null=True, blank=True)), 31 | ], 32 | options={ 33 | 'permissions': (('read', 'Can search images, and see the detail data'), ('crop', 'Can crop images')), 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0002_auto_20141203_2115.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import betty.cropper.models 6 | import django.core.files.storage 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('cropper', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='image', 18 | name='optimized', 19 | field=models.FileField(storage=django.core.files.storage.FileSystemStorage(base_url='/', location='/Users/csinchok/Development/betty-cropper/images'), max_length=255, null=True, upload_to=betty.cropper.models.optimized_upload_to, blank=True), 20 | preserve_default=True, 21 | ), 22 | migrations.AlterField( 23 | model_name='image', 24 | name='source', 25 | field=models.FileField(storage=django.core.files.storage.FileSystemStorage(base_url='/', location='/Users/csinchok/Development/betty-cropper/images'), max_length=255, null=True, upload_to=betty.cropper.models.source_upload_to, blank=True), 26 | preserve_default=True, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0002_image_last_modified.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | from django.utils import timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cropper', '0001_squashed_0004_auto_20160317_1706'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='image', 17 | name='last_modified', 18 | field=models.DateTimeField(default=timezone.now(), auto_now=True), 19 | preserve_default=False, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0003_image_jpeg_qualities.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import jsonfield.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cropper', '0002_auto_20141203_2115'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='image', 17 | name='jpeg_quality_settings', 18 | field=jsonfield.fields.JSONField(null=True, blank=True), 19 | preserve_default=True, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /betty/cropper/migrations/0004_auto_20160317_1706.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | import betty.cropper.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('cropper', '0003_image_jpeg_qualities'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='image', 17 | name='optimized', 18 | field=models.FileField(null=True, blank=True, max_length=255, upload_to=betty.cropper.models.optimized_upload_to), 19 | ), 20 | migrations.AlterField( 21 | model_name='image', 22 | name='source', 23 | field=models.FileField(null=True, blank=True, max_length=255, upload_to=betty.cropper.models.source_upload_to), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /betty/cropper/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/cropper/migrations/__init__.py -------------------------------------------------------------------------------- /betty/cropper/storage.py: -------------------------------------------------------------------------------- 1 | from storages.backends.s3boto import S3BotoStorage 2 | 3 | from betty.conf.app import settings 4 | 5 | logger = __import__('logging').getLogger(__name__) 6 | 7 | 8 | class MigratedS3BotoStorage(S3BotoStorage): 9 | 10 | """Workaround for allowing using 2 different storage systems in parallel during migration to S3 11 | storage. 12 | 13 | Use this storage intead of S3BotoStorage to allow easy re-wiring of path locations from 14 | filesystem to S3-based. 15 | 16 | Required Settings: 17 | BETTY_STORAGE_MIGRATION_OLD_ROOT - Old localfilesystem root directory 18 | BETTY_STORAGE_MIGRATION_NEW_ROOT - S3 key root 19 | """ 20 | 21 | def _clean_name(self, name): 22 | if name.startswith(settings.BETTY_STORAGE_MIGRATION_OLD_ROOT): 23 | old_name = name 24 | name = (settings.BETTY_STORAGE_MIGRATION_NEW_ROOT + 25 | name[len(settings.BETTY_STORAGE_MIGRATION_OLD_ROOT):]) 26 | logger.info('Remap name: %s --> %s', old_name, name) 27 | return super(MigratedS3BotoStorage, self)._clean_name(name) 28 | -------------------------------------------------------------------------------- /betty/cropper/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import io 4 | 5 | from celery import shared_task 6 | from PIL import Image as PILImage 7 | 8 | from betty.conf.app import settings 9 | 10 | from .dssim import detect_optimal_quality 11 | try: 12 | # Legacy check: Try import here to determine if we should enable IMGMIN 13 | import numpy # NOQA 14 | import scipy # NOQA 15 | IMGMIN_DISABLED = False 16 | except ImportError: 17 | IMGMIN_DISABLED = True 18 | 19 | 20 | def is_optimized(image_field): 21 | """Checks if the image is already optimized 22 | 23 | For our purposes, we check to see if the existing file will be smaller than 24 | a version saved at the default quality (80).""" 25 | 26 | source_buffer = image_field.read_source_bytes() 27 | 28 | im = PILImage.open(source_buffer) 29 | icc_profile = im.info.get("icc_profile") 30 | 31 | # First, let's check to make sure that this image isn't already an optimized JPEG 32 | if im.format == "JPEG": 33 | optimized_buffer = io.BytesIO() 34 | im.save( 35 | optimized_buffer, 36 | format="JPEG", 37 | quality=settings.BETTY_DEFAULT_JPEG_QUALITY, 38 | icc_profile=icc_profile, 39 | optimize=True) 40 | # Note: .getbuffer().nbytes is preferred, but not supported in Python 2.7 41 | if len(source_buffer.getvalue()) < len(optimized_buffer.getvalue()): 42 | # Looks like the original was already compressed, let's bail. 43 | return True 44 | 45 | return False 46 | 47 | 48 | @shared_task 49 | def search_image_quality(image_id): 50 | if IMGMIN_DISABLED: 51 | return 52 | 53 | from betty.cropper.models import Image 54 | 55 | image = Image.objects.get(id=image_id) 56 | 57 | if is_optimized(image): 58 | # If the image is already optimized, let's leave this alone... 59 | return 60 | 61 | # Read buffer from storage once and reset on each iteration 62 | optimized_buffer = image.read_optimized_bytes() 63 | image.jpeg_quality_settings = {} 64 | last_width = 0 65 | for width in sorted(settings.BETTY_WIDTHS, reverse=True): 66 | 67 | if abs(last_width - width) < 100: 68 | # Sometimes the widths are really too close. We only need to check every 100 px 69 | continue 70 | 71 | if width > 0: 72 | optimized_buffer.seek(0) 73 | quality = detect_optimal_quality(optimized_buffer, width) 74 | image.jpeg_quality_settings[width] = quality 75 | 76 | if quality == settings.BETTY_JPEG_QUALITY_RANGE[-1]: 77 | # We'are already at max... 78 | break 79 | 80 | last_width = width 81 | 82 | image.save() 83 | image.clear_crops() 84 | -------------------------------------------------------------------------------- /betty/cropper/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Betty Cropper 6 | 7 | 8 | Not found. 9 | 10 | -------------------------------------------------------------------------------- /betty/cropper/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Betty Cropper 6 | 7 | 8 | Server Error. 9 | 10 | -------------------------------------------------------------------------------- /betty/cropper/templates/image.js: -------------------------------------------------------------------------------- 1 | (function(w){ 2 | /* We can request an image at every possible width, but let's limit it to a reasonable number 3 | We can set these so they correspond to our more common sizes. 4 | */ 5 | var IMAGE_URL = w.BETTY_IMAGE_URL || "{{ BETTY_IMAGE_URL }}", 6 | RATIOS = {{ BETTY_RATIOS|safe }}, 7 | ASPECT_RATIO_TOLERANCE = .1, // 10% tolerance. 8 | MAX_WIDTH = {{ BETTY_MAX_WIDTH }}, 9 | PICTUREFILL_SELECTOR = w.PICTUREFILL_SELECTOR || "div", 10 | breakpoints = [{{ BETTY_WIDTHS|join:","}}]; 11 | 12 | // Credit to https://remysharp.com/2010/07/21/throttling-function-calls 13 | function throttle(fn, threshhold, scope) { 14 | threshhold || (threshhold = 250); 15 | var last, 16 | deferTimer; 17 | return function () { 18 | var context = scope || this; 19 | 20 | var now = +new Date, 21 | args = arguments; 22 | if (last && now < last + threshhold) { 23 | // hold on to it 24 | clearTimeout(deferTimer); 25 | deferTimer = setTimeout(function () { 26 | last = now; 27 | fn.apply(context, args); 28 | }, threshhold); 29 | } else { 30 | last = now; 31 | fn.apply(context, args); 32 | } 33 | }; 34 | } 35 | 36 | w.picturefill = function picturefill (elements, forceRerender) { 37 | // It is sometimes desirable to scroll without loading images as we go. 38 | if (picturefill.paused()) { 39 | return; 40 | } 41 | // get elements to picturefill 42 | var ps; 43 | if (elements instanceof Array) { 44 | ps = elements; 45 | } else if (elements instanceof HTMLElement) { 46 | ps = [elements]; 47 | } else { 48 | ps = w.document.querySelectorAll(PICTUREFILL_SELECTOR); 49 | } 50 | 51 | // loop through elements and fill them in 52 | var imageData = []; 53 | for (var i = 0, il = ps.length; i < il; i++){ 54 | var el = ps[i]; 55 | 56 | // ensure this element is actually a image to picturefill 57 | if(el.getAttribute("data-type") !== "image" ){ 58 | // not image to fill, skip this one 59 | continue; 60 | } 61 | 62 | // check if image is in viewport for lazy loading, and 63 | // preload images if they're within 100px of being shown above scroll, 64 | // within 250px of being shown below scroll. 65 | var elementRect = el.getBoundingClientRect(), 66 | innerHeight = w.innerHeight || w.document.documentElement.clientHeight, 67 | visible = elementRect.top <= (innerHeight + 250) && elementRect.top >= -100; 68 | 69 | // this is a div to picturefill, start working on it if it hasn't been rendered yet 70 | if (el.getAttribute("data-image-id") !== null 71 | && visible 72 | && (forceRerender || !el.getAttribute("data-rendered"))) { 73 | var imageContainer = el.getElementsByTagName("div")[0], 74 | imageId = el.getAttribute("data-image-id"), 75 | imageCrop = el.getAttribute("data-crop"), 76 | format = el.getAttribute("data-format") || "jpg"; 77 | 78 | // construct ID path for image 79 | var idStr = ""; 80 | for(var ii = 0; ii < imageId.length; ii++) { 81 | if ((ii % 4) === 0) { 82 | idStr += "/"; 83 | } 84 | idStr += imageId.charAt(ii); 85 | } 86 | 87 | // find any existing img element in the picture element 88 | var picImg = imageContainer.getElementsByTagName("img")[0]; 89 | if(!picImg){ 90 | // for performance reasons this will be added to the dom later 91 | picImg = w.document.createElement("img"); 92 | 93 | var alt = el.getAttribute("data-alt"); 94 | if (alt) { 95 | picImg.alt = alt; 96 | } 97 | } 98 | 99 | // determine what to do based on format 100 | if (format === "gif") { 101 | // for GIFs, we just dump out original 102 | imageData.push({ 103 | 'div': imageContainer, 104 | 'img': picImg, 105 | 'url': IMAGE_URL + idStr + "/animated/original.gif" 106 | }); 107 | } else { 108 | // determine size & crop for PNGs & JPGs. 109 | var _w = imageContainer.offsetWidth, 110 | _h = imageContainer.offsetHeight; 111 | 112 | if (!imageCrop || imageCrop === "") { 113 | imageCrop = computeAspectRatio(_w, _h); 114 | } 115 | 116 | // scale up to the pixel ratio if there's some pixel ratio defined 117 | if (w.devicePixelRatio) { 118 | _w = Math.round(w.devicePixelRatio * _w); 119 | _h = Math.round(w.devicePixelRatio * _h); 120 | } 121 | 122 | // determine if a breakpoint width should be used, otherwise use previously defined width 123 | var width = null; 124 | for (var j = 0; j < breakpoints.length; j++) { 125 | if (_w <= breakpoints[j]) { 126 | width = breakpoints[j]; 127 | break; 128 | } 129 | } 130 | if (width === null) { 131 | if (_w > MAX_WIDTH) { 132 | width = MAX_WIDTH; 133 | } else { 134 | width = _w; 135 | } 136 | } 137 | 138 | // if the existing image is larger (or the same) than the one we're about to load, do not update. 139 | // however if the crop changes, we need to reload. 140 | if (width > 0) { 141 | //ie8 doesn't support natural width, always load. 142 | if (typeof picImg.naturalWidth === "undefined" || picImg.naturalWidth < width 143 | || imageCrop !== computeAspectRatio(picImg.naturalWidth, picImg.naturalHeight)) { 144 | // put image in image data to render 145 | imageData.push({ 146 | 'div': imageContainer, 147 | 'img': picImg, 148 | 'url': IMAGE_URL + idStr + "/" + imageCrop + "/" + width + "." + format 149 | }); 150 | } 151 | } 152 | } 153 | } 154 | } 155 | // loop through image data and insert images, all DOM updates should probably go here 156 | for(var i = 0; i < imageData.length; i++) { 157 | var data = imageData[i]; 158 | data.img.src = data.url; 159 | if (!data.img.parentNode) { 160 | data.div.appendChild(data.img); 161 | data.div.parentNode.setAttribute("data-rendered", "true"); 162 | } 163 | } 164 | }; 165 | 166 | /** 167 | * picturefill pause and resume. 168 | * Useful to prevent loading unneccessary images, such as when scrolling 169 | * the reading list. 170 | */ 171 | var isPaused = false; 172 | picturefill.pause = function () { 173 | isPaused = true; 174 | }; 175 | 176 | picturefill.resume = function () { 177 | isPaused = false; 178 | picturefill(); 179 | }; 180 | 181 | picturefill.paused = function () { 182 | return isPaused; 183 | }; 184 | 185 | /** 186 | * Figure out best aspect ratio based on width, height, and given aspect ratios. 187 | */ 188 | function computeAspectRatio(_w, _h) { 189 | if (_w !== 0 && _h !== 0) { 190 | var aspectRatio = _w/_h; 191 | for (var i in RATIOS) { 192 | if (Math.abs(aspectRatio - RATIOS[i][1]) / RATIOS[i][1] < ASPECT_RATIO_TOLERANCE) { 193 | return RATIOS[i][0]; 194 | } 195 | } 196 | } 197 | // No suitable ratio, use default. 198 | return "16x9"; 199 | } 200 | 201 | function addEventListener(ele, event, callback) { 202 | if (ele.addEventListener) { 203 | ele.addEventListener(event, callback, false); 204 | } else if (ele.attachEvent) { 205 | ele.attachEvent("on" + event, callback); 206 | } 207 | } 208 | 209 | function removeEventListener(ele, event, callback) { 210 | if (ele.removeEventListener) { 211 | ele.removeEventListener(event, callback, false); 212 | } else if (ele.detachEvent) { 213 | ele.detachEvent("on" + event, callback); 214 | } 215 | } 216 | 217 | // Run on resize and domready (w.load as a fallback) 218 | if (!w.IMAGE_LISTENERS_DISABLED) { 219 | 220 | addEventListener(w, "load", picturefill); 221 | addEventListener(w, "DOMContentLoaded", function () { 222 | picturefill(); 223 | removeEventListener(w, "load"); 224 | }); 225 | 226 | var resizeTimeout; 227 | addEventListener(w, "resize", function () { 228 | clearTimeout(resizeTimeout); 229 | resizeTimeout = setTimeout(function () { 230 | picturefill(null, true); 231 | }, 100); 232 | }); 233 | 234 | addEventListener(w, "scroll", throttle(picturefill, 100)); 235 | 236 | } 237 | 238 | }(this)); 239 | -------------------------------------------------------------------------------- /betty/cropper/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url, include 2 | 3 | urlpatterns = patterns( 4 | 'betty.cropper.views', 5 | url(r'^image\.js$', "image_js"), 6 | url(r'^(?P\d{5,})/(?P[a-z0-9]+)/(?P\d+)\.(?P(jpg|png))$', 7 | 'redirect_crop'), 8 | url(r'^(?P[0-9/]+)/(?P[a-z0-9]+)/(?P\d+)\.(?P(jpg|png))$', 9 | 'crop'), 10 | url(r'^(?P[0-9/]+)/animated/original\.(?P(jpg|gif))$', 11 | 'animated'), 12 | url(r'^(?P[0-9/]+)/source$', 13 | 'source'), 14 | url(r'^api/', include("betty.cropper.api.urls")), 15 | ) 16 | -------------------------------------------------------------------------------- /betty/cropper/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | # Necessary b/c Python<3.3 doesn't support datetime.timestamp() 5 | def seconds_since_epoch(when): 6 | return int(time.mktime(when.timetuple())) 7 | -------------------------------------------------------------------------------- /betty/cropper/utils/http.py: -------------------------------------------------------------------------------- 1 | from django.utils.http import parse_http_date_safe 2 | 3 | from betty.cropper.utils import seconds_since_epoch 4 | 5 | 6 | def check_not_modified(request, last_modified): 7 | """Handle 304/If-Modified-Since 8 | 9 | With Django v1.9.5+ could just use "django.utils.cache.get_conditional_response", but v1.9 is 10 | not supported by "logan" dependancy (yet). 11 | """ 12 | 13 | if_modified_since = parse_http_date_safe(request.META.get('HTTP_IF_MODIFIED_SINCE')) 14 | return (last_modified and 15 | if_modified_since and 16 | seconds_since_epoch(last_modified) <= if_modified_since) 17 | -------------------------------------------------------------------------------- /betty/cropper/utils/placeholder.py: -------------------------------------------------------------------------------- 1 | import io 2 | import random 3 | 4 | from PIL import Image, ImageDraw, ImageFont 5 | 6 | from betty.cropper.models import Ratio 7 | from betty.conf.app import settings 8 | 9 | 10 | def placeholder(ratio, width, extension): 11 | if ratio.string == "original": 12 | ratio = Ratio(random.choice((settings.BETTY_RATIOS))) 13 | height = int(round((width * ratio.height / float(ratio.width)))) 14 | 15 | bg_fill = random.choice(settings.BETTY_PLACEHOLDER_COLORS) 16 | img = Image.new("RGB", (width, height), bg_fill) 17 | 18 | draw = ImageDraw.Draw(img) 19 | 20 | font = ImageFont.truetype(filename=settings.BETTY_PLACEHOLDER_FONT, size=45) 21 | text_size = draw.textsize(ratio.string, font=font) 22 | text_coords = ( 23 | int(round((width - text_size[0]) / 2.0)), 24 | int(round((height - text_size[1]) / 2) - 15), 25 | ) 26 | draw.text(text_coords, ratio.string, font=font, fill=(256, 256, 256)) 27 | if extension == 'jpg': 28 | pillow_kwargs = {"format": "jpeg", "quality": 80} 29 | if extension == 'png': 30 | pillow_kwargs = {"format": "png"} 31 | 32 | tmp = io.BytesIO() 33 | img.save(tmp, **pillow_kwargs) 34 | return tmp.getvalue() 35 | -------------------------------------------------------------------------------- /betty/cropper/utils/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from logan.runner import run_app, configure_app 4 | 5 | BETTY_IMAGE_ROOT = os.path.normpath(os.path.join(os.getcwd(), "images")) 6 | 7 | 8 | def generate_settings(): 9 | """ 10 | This command is run when ``default_path`` doesn't exist, or ``init`` is 11 | run and returns a string representing the default data to put into their 12 | settings file. 13 | """ 14 | return """ 15 | BETTY_IMAGE_ROOT = "{0}" 16 | BETTY_IMAGE_URL = "/" 17 | BETTY_RATIOS = ("1x1", "2x1", "3x1", "3x4", "4x3", "16x9") 18 | BETTY_PLACEHOLDER = True 19 | """.format(BETTY_IMAGE_ROOT) 20 | 21 | 22 | def configure(): 23 | configure_app( 24 | project='betty', 25 | default_config_path='betty.conf.py', 26 | default_settings='betty.conf.server', 27 | settings_initializer=generate_settings, 28 | settings_envvar='BETTY_CONF', 29 | ) 30 | 31 | 32 | def main(): 33 | run_app( 34 | project='betty', 35 | default_config_path='betty.conf.py', 36 | default_settings='betty.conf.server', 37 | settings_initializer=generate_settings, 38 | settings_envvar='BETTY_CONF', 39 | ) 40 | 41 | if __name__ == '__main__': 42 | main() 43 | -------------------------------------------------------------------------------- /betty/cropper/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from betty.conf.app import settings 3 | 4 | from django.http import (Http404, HttpResponse, HttpResponseNotModified, 5 | HttpResponseServerError, HttpResponseRedirect) 6 | from django.shortcuts import render 7 | from django.utils.cache import patch_cache_control 8 | from django.utils.http import http_date 9 | 10 | from django.views.decorators.cache import cache_control 11 | from six.moves import urllib 12 | 13 | from .models import Image, Ratio 14 | from .utils.http import check_not_modified 15 | from .utils.placeholder import placeholder 16 | 17 | logger = __import__('logging').getLogger(__name__) 18 | 19 | 20 | EXTENSION_MAP = { 21 | "gif": { 22 | "format": "gif", 23 | "mime_type": "image/gif" 24 | }, 25 | "jpg": { 26 | "format": "jpeg", 27 | "mime_type": "image/jpeg" 28 | }, 29 | "png": { 30 | "format": "png", 31 | "mime_type": "image/png" 32 | }, 33 | # Some Clickhole source images are actually PSD files disguised as JPG 34 | "psd": { 35 | "format": "psd", 36 | "mime_type": "image/vnd.adobe.photoshop" 37 | }, 38 | "tiff": { 39 | "format": "tiff", 40 | "mime_type": "image/tiff" 41 | }, 42 | } 43 | 44 | FORMAT_TO_MIME_TYPE_MAP = {f['format']: f['mime_type'] for f in EXTENSION_MAP.values()} 45 | 46 | 47 | @cache_control(max_age=settings.BETTY_CACHE_IMAGEJS_SEC) 48 | def image_js(request): 49 | widths = set(settings.BETTY_WIDTHS + settings.BETTY_CLIENT_ONLY_WIDTHS) 50 | # Ensure '0' always present 51 | widths.add(0) 52 | 53 | betty_image_url = settings.BETTY_IMAGE_URL 54 | # make the url protocol-relative 55 | url_parts = list(urllib.parse.urlparse(betty_image_url)) 56 | url_parts[0] = "" 57 | if settings.BETTY_IMAGE_URL_USE_REQUEST_HOST: 58 | # Prefer requested host (allows serving against multiple domains). 59 | # Make sure settings.ALLOWED_HOSTS is set to avoid spoofing. 60 | url_parts[1] = request.get_host() 61 | betty_image_url = urllib.parse.urlunparse(url_parts) 62 | if betty_image_url.endswith("/"): 63 | betty_image_url = betty_image_url[:-1] 64 | context = { 65 | "BETTY_IMAGE_URL": betty_image_url, 66 | "BETTY_WIDTHS": sorted(widths), 67 | "BETTY_MAX_WIDTH": settings.BETTY_MAX_WIDTH 68 | } 69 | BETTY_RATIOS = [] 70 | ratios_sorted = sorted(settings.BETTY_RATIOS, 71 | key=lambda r: Ratio(r).width / float(Ratio(r).height)) 72 | for ratio_string in ratios_sorted: 73 | ratio = Ratio(ratio_string) 74 | BETTY_RATIOS.append((ratio_string, ratio.width / float(ratio.height))) 75 | context["BETTY_RATIOS"] = json.dumps(BETTY_RATIOS) 76 | 77 | return render(request, "image.js", context, content_type="application/javascript") 78 | 79 | 80 | @cache_control(max_age=(60 * 60)) 81 | def redirect_crop(request, id, ratio_slug, width, extension): 82 | image_id = int(id.replace("/", "")) 83 | 84 | """ 85 | This is a little bit of a hack, but basically, we just make a disposable image object, 86 | so that we can use it to generate a full URL. 87 | """ 88 | image = Image(id=image_id) 89 | 90 | return HttpResponseRedirect(image.get_absolute_url(ratio=ratio_slug, width=width, 91 | extension=extension)) 92 | 93 | 94 | def _image_response(image_blob, image_format=None, extension=None): 95 | resp = HttpResponse(image_blob) 96 | 97 | # Legacy betty cropper keys off of file extension, but some newer routes auto-detect format 98 | if extension: 99 | image_format = EXTENSION_MAP[extension]['format'] 100 | resp["Content-Type"] = FORMAT_TO_MIME_TYPE_MAP[image_format] 101 | 102 | resp['Last-Modified'] = http_date() 103 | return resp 104 | 105 | 106 | def crop(request, id, ratio_slug, width, extension): 107 | if ratio_slug != "original" and ratio_slug not in settings.BETTY_RATIOS: 108 | raise Http404 109 | 110 | try: 111 | ratio = Ratio(ratio_slug) 112 | except ValueError: 113 | raise Http404 114 | 115 | width = int(width) 116 | 117 | if width > settings.BETTY_MAX_WIDTH: 118 | return HttpResponseServerError("Invalid width") 119 | 120 | image_id = int(id.replace("/", "")) 121 | 122 | try: 123 | image = Image.objects.get(id=image_id) 124 | except Image.DoesNotExist: 125 | if settings.BETTY_PLACEHOLDER: 126 | img_blob = placeholder(ratio, width, extension) 127 | resp = HttpResponse(img_blob) 128 | resp["Cache-Control"] = "no-cache, no-store, must-revalidate" 129 | resp["Pragma"] = "no-cache" 130 | resp["Expires"] = "0" 131 | resp["Content-Type"] = EXTENSION_MAP[extension]["mime_type"] 132 | return resp 133 | else: 134 | raise Http404 135 | 136 | if check_not_modified(request=request, last_modified=image.last_modified): 137 | # Avoid hitting storage backend on cache update 138 | resp = HttpResponseNotModified() 139 | else: 140 | try: 141 | image_blob = image.crop(ratio, width, extension) 142 | except Exception: 143 | logger.exception("Cropping error") 144 | return HttpResponseServerError("Cropping error") 145 | 146 | resp = _image_response(image_blob, extension=extension) 147 | 148 | # Optionally specify alternate cache duration for non-breakpoint widths. 149 | # This is useful b/c cache flush callback only receives paths for known breakpoints, so this 150 | # allows non-standard widths to have a shorter cache time. This wouldn't be necessary if cache 151 | # flush used wildcards instead, or if you could guarantee/force only defined breakpoint widths. 152 | max_age = settings.BETTY_CACHE_CROP_SEC 153 | if settings.BETTY_CACHE_CROP_NON_BREAKPOINT_SEC is not None: 154 | if width not in (settings.BETTY_WIDTHS + settings.BETTY_CLIENT_ONLY_WIDTHS): 155 | max_age = settings.BETTY_CACHE_CROP_NON_BREAKPOINT_SEC 156 | patch_cache_control(resp, max_age=max_age) 157 | return resp 158 | 159 | 160 | # Get original source asset 161 | def source(request, id): 162 | 163 | image_id = int(id.replace("/", "")) 164 | 165 | try: 166 | image = Image.objects.get(id=image_id) 167 | except Image.DoesNotExist: 168 | raise Http404 169 | 170 | if check_not_modified(request=request, last_modified=image.last_modified): 171 | # Avoid hitting storage backend on cache update 172 | resp = HttpResponseNotModified() 173 | else: 174 | try: 175 | image_blob, image_format = image.get_source() 176 | except Exception: 177 | logger.exception("Source error") 178 | return HttpResponseServerError("Source error") 179 | 180 | resp = _image_response(image_blob, image_format=image_format) 181 | 182 | patch_cache_control(resp, max_age=settings.BETTY_CACHE_CROP_SEC) 183 | return resp 184 | 185 | 186 | # Legacy behavior -- originally these were just dropped on filesystem and let NGINX frontend serve 187 | # automatically via try-files. 188 | def animated(request, id, extension): 189 | 190 | image_id = int(id.replace("/", "")) 191 | 192 | try: 193 | image = Image.objects.get(id=image_id) 194 | except Image.DoesNotExist: 195 | raise Http404 196 | 197 | if not image.animated: 198 | raise Http404 199 | 200 | if check_not_modified(request=request, last_modified=image.last_modified): 201 | # Avoid hitting storage backend on cache update 202 | resp = HttpResponseNotModified() 203 | else: 204 | try: 205 | image_blob = image.get_animated(extension=extension) 206 | except Exception: 207 | logger.exception("Animated error") 208 | return HttpResponseServerError("Animated error") 209 | 210 | resp = _image_response(image_blob, extension=extension) 211 | 212 | patch_cache_control(resp, max_age=settings.BETTY_CACHE_CROP_SEC) 213 | return resp 214 | -------------------------------------------------------------------------------- /betty/image_browser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/image_browser/__init__.py -------------------------------------------------------------------------------- /betty/image_browser/static/betty/css/Jcrop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/image_browser/static/betty/css/Jcrop.gif -------------------------------------------------------------------------------- /betty/image_browser/static/betty/css/jquery.Jcrop.min.css: -------------------------------------------------------------------------------- 1 | /* jquery.Jcrop.min.css v0.9.12 (build:20130126) */ 2 | .jcrop-holder{direction:ltr;text-align:left;} 3 | .jcrop-vline,.jcrop-hline{background:#FFF url(Jcrop.gif);font-size:0;position:absolute;} 4 | .jcrop-vline{height:100%;width:1px!important;} 5 | .jcrop-vline.right{right:0;} 6 | .jcrop-hline{height:1px!important;width:100%;} 7 | .jcrop-hline.bottom{bottom:0;} 8 | .jcrop-tracker{-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;height:100%;width:100%;} 9 | .jcrop-handle{background-color:#333;border:1px #EEE solid;font-size:1px;height:7px;width:7px;} 10 | .jcrop-handle.ord-n{left:50%;margin-left:-4px;margin-top:-4px;top:0;} 11 | .jcrop-handle.ord-s{bottom:0;left:50%;margin-bottom:-4px;margin-left:-4px;} 12 | .jcrop-handle.ord-e{margin-right:-4px;margin-top:-4px;right:0;top:50%;} 13 | .jcrop-handle.ord-w{left:0;margin-left:-4px;margin-top:-4px;top:50%;} 14 | .jcrop-handle.ord-nw{left:0;margin-left:-4px;margin-top:-4px;top:0;} 15 | .jcrop-handle.ord-ne{margin-right:-4px;margin-top:-4px;right:0;top:0;} 16 | .jcrop-handle.ord-se{bottom:0;margin-bottom:-4px;margin-right:-4px;right:0;} 17 | .jcrop-handle.ord-sw{bottom:0;left:0;margin-bottom:-4px;margin-left:-4px;} 18 | .jcrop-dragbar.ord-n,.jcrop-dragbar.ord-s{height:7px;width:100%;} 19 | .jcrop-dragbar.ord-e,.jcrop-dragbar.ord-w{height:100%;width:7px;} 20 | .jcrop-dragbar.ord-n{margin-top:-4px;} 21 | .jcrop-dragbar.ord-s{bottom:0;margin-bottom:-4px;} 22 | .jcrop-dragbar.ord-e{margin-right:-4px;right:0;} 23 | .jcrop-dragbar.ord-w{margin-left:-4px;} 24 | .jcrop-light .jcrop-vline,.jcrop-light .jcrop-hline{background:#FFF;filter:alpha(opacity=70)!important;opacity:.70!important;} 25 | .jcrop-light .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#000;border-color:#FFF;border-radius:3px;} 26 | .jcrop-dark .jcrop-vline,.jcrop-dark .jcrop-hline{background:#000;filter:alpha(opacity=70)!important;opacity:.7!important;} 27 | .jcrop-dark .jcrop-handle{-moz-border-radius:3px;-webkit-border-radius:3px;background-color:#FFF;border-color:#000;border-radius:3px;} 28 | .solid-line .jcrop-vline,.solid-line .jcrop-hline{background:#FFF;} 29 | .jcrop-holder img,img.jcrop-preview{max-width:none;} 30 | -------------------------------------------------------------------------------- /betty/image_browser/static/betty/css/style.css: -------------------------------------------------------------------------------- 1 | .hide-overflow { max-width: 100%; overflow: hidden; text-overflow: ellipsis; } 2 | 3 | #results { padding: 10px 0 0 } 4 | 5 | #results .popover { min-width: 150px } 6 | 7 | #results .popover dt { margin-top: 5px } 8 | 9 | #results .popover dt:first-child { margin-top: 0 } 10 | 11 | #results li { text-align: center; margin-bottom: 10px; } 12 | 13 | #results li img { max-width: 100% } 14 | 15 | .details { display: block } 16 | 17 | #size-select .dropdown-menu { min-width: 200px } 18 | 19 | #infscr-loading { clear: both; display: block; width: 100%; text-align: center; } 20 | 21 | #upload-modal .upload-well { font-size: 5em; text-align: center; cursor: pointer; } 22 | 23 | #upload-modal .upload-well:hover { background-color: #e3e3e3; border-color: #d1d1d1; } 24 | 25 | #upload-modal .image-form { display: none } 26 | 27 | #upload-modal input.image-picker { display: none } 28 | 29 | .spin { 30 | -webkit-animation: spin 2s infinite linear; 31 | -moz-animation: spin 2s infinite linear; 32 | -o-animation: spin 2s infinite linear; 33 | animation: spin 2s infinite linear; 34 | -webkit-transform-origin: 50% 58%; 35 | transform-origin:50% 58%; 36 | -ms-transform-origin:50% 58%; /* IE 9 */ 37 | } 38 | 39 | @-moz-keyframes spin { 40 | from { 41 | -moz-transform: rotate(0deg); 42 | } 43 | to { 44 | -moz-transform: rotate(360deg); 45 | } 46 | } 47 | 48 | @-webkit-keyframes spin { 49 | from { 50 | -webkit-transform: rotate(0deg); 51 | } 52 | to { 53 | -webkit-transform: rotate(360deg); 54 | } 55 | } 56 | 57 | @keyframes spin { 58 | from { 59 | transform: rotate(0deg); 60 | } 61 | to { 62 | transform: rotate(360deg); 63 | } 64 | } 65 | 66 | @media (max-width: 767px) { 67 | 68 | #search-images { padding: 0 10px 10px; clear: both; } 69 | 70 | .visible-xs .btn-group { padding: 10px } 71 | 72 | .popover { display: none !important } } 73 | 74 | @media (min-width: 768px) { 75 | 76 | .details { display: none } 77 | 78 | #results .media, #results .media-body { overflow: visible } 79 | 80 | .collapse { display: block } 81 | 82 | #search-nav { padding: 10px 0 } } 83 | -------------------------------------------------------------------------------- /betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/betty/image_browser/static/betty/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/crop.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | function initCropModal() { 5 | // Get image and crop data 6 | $.ajax({ 7 | url: 8 | }) 9 | 10 | // Build the crop previews 11 | 12 | 13 | } -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/index.js: -------------------------------------------------------------------------------- 1 | $('#scroll').infinitescroll({ 2 | navSelector : "#pagination", 3 | nextSelector : "a#next", 4 | itemSelector : "#scroll li", 5 | animate : true, 6 | donetext : "End of available images." 7 | },function(){ initPopover(); }); 8 | 9 | $(window).unbind('.infscr'); 10 | 11 | $("#next-trigger .btn").click(function(){ 12 | $('#scroll').infinitescroll('retrieve'); 13 | return false; 14 | }); 15 | 16 | $(document).ajaxError(function (e, xhr, opt) { 17 | if (xhr.status == 404) $('a#next').remove(); 18 | }); 19 | 20 | $(document).ready(function(){ 21 | initPopover(); 22 | 23 | $('#upload-modal').on("loaded.bs.modal", function(e){ 24 | initUploadModal(this); 25 | }); 26 | $('#upload-modal').on("hidden.bs.modal", function(e){ 27 | clearUploadModal(this); 28 | }); 29 | }); 30 | 31 | $(document.body).on('click', '#size-select li', function (event) { 32 | var $t = $(event.currentTarget), 33 | l = $(this).find('a'); 34 | $('#size').val(l.attr('data-title')); 35 | $t.closest('.input-group-btn') 36 | .find('[data-bind="label"]').text($t.text()) 37 | .end() 38 | .children('.dropdown-toggle').dropdown('toggle'); 39 | return false; 40 | }); 41 | 42 | function initPopover() { 43 | $('#results li a').popover({ 44 | html : true, 45 | content: function() { return $(this).closest('li').find('.details').html(); }, 46 | trigger: 'hover', 47 | placement: 'auto', 48 | delay: { show: 500, hide: 100 }, 49 | title: 'Details' 50 | }); 51 | } -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/jquery.Jcrop.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jquery.Jcrop.min.js v0.9.12 (build:20130202) 3 | * jQuery Image Cropping Plugin - released under MIT License 4 | * Copyright (c) 2008-2013 Tapmodo Interactive LLC 5 | * https://github.com/tapmodo/Jcrop 6 | */ 7 | (function(a){a.Jcrop=function(b,c){function i(a){return Math.round(a)+"px"}function j(a){return d.baseClass+"-"+a}function k(){return a.fx.step.hasOwnProperty("backgroundColor")}function l(b){var c=a(b).offset();return[c.left,c.top]}function m(a){return[a.pageX-e[0],a.pageY-e[1]]}function n(b){typeof b!="object"&&(b={}),d=a.extend(d,b),a.each(["onChange","onSelect","onRelease","onDblClick"],function(a,b){typeof d[b]!="function"&&(d[b]=function(){})})}function o(a,b,c){e=l(D),bc.setCursor(a==="move"?a:a+"-resize");if(a==="move")return bc.activateHandlers(q(b),v,c);var d=_.getFixed(),f=r(a),g=_.getCorner(r(f));_.setPressed(_.getCorner(f)),_.setCurrent(g),bc.activateHandlers(p(a,d),v,c)}function p(a,b){return function(c){if(!d.aspectRatio)switch(a){case"e":c[1]=b.y2;break;case"w":c[1]=b.y2;break;case"n":c[0]=b.x2;break;case"s":c[0]=b.x2}else switch(a){case"e":c[1]=b.y+1;break;case"w":c[1]=b.y+1;break;case"n":c[0]=b.x+1;break;case"s":c[0]=b.x+1}_.setCurrent(c),bb.update()}}function q(a){var b=a;return bd.watchKeys 8 | (),function(a){_.moveOffset([a[0]-b[0],a[1]-b[1]]),b=a,bb.update()}}function r(a){switch(a){case"n":return"sw";case"s":return"nw";case"e":return"nw";case"w":return"ne";case"ne":return"sw";case"nw":return"se";case"se":return"nw";case"sw":return"ne"}}function s(a){return function(b){return d.disabled?!1:a==="move"&&!d.allowMove?!1:(e=l(D),W=!0,o(a,m(b)),b.stopPropagation(),b.preventDefault(),!1)}}function t(a,b,c){var d=a.width(),e=a.height();d>b&&b>0&&(d=b,e=b/a.width()*a.height()),e>c&&c>0&&(e=c,d=c/a.height()*a.width()),T=a.width()/d,U=a.height()/e,a.width(d).height(e)}function u(a){return{x:a.x*T,y:a.y*U,x2:a.x2*T,y2:a.y2*U,w:a.w*T,h:a.h*U}}function v(a){var b=_.getFixed();b.w>d.minSelect[0]&&b.h>d.minSelect[1]?(bb.enableHandles(),bb.done()):bb.release(),bc.setCursor(d.allowSelect?"crosshair":"default")}function w(a){if(d.disabled)return!1;if(!d.allowSelect)return!1;W=!0,e=l(D),bb.disableHandles(),bc.setCursor("crosshair");var b=m(a);return _.setPressed(b),bb.update(),bc.activateHandlers(x,v,a.type.substring 9 | (0,5)==="touch"),bd.watchKeys(),a.stopPropagation(),a.preventDefault(),!1}function x(a){_.setCurrent(a),bb.update()}function y(){var b=a("
").addClass(j("tracker"));return g&&b.css({opacity:0,backgroundColor:"white"}),b}function be(a){G.removeClass().addClass(j("holder")).addClass(a)}function bf(a,b){function t(){window.setTimeout(u,l)}var c=a[0]/T,e=a[1]/U,f=a[2]/T,g=a[3]/U;if(X)return;var h=_.flipCoords(c,e,f,g),i=_.getFixed(),j=[i.x,i.y,i.x2,i.y2],k=j,l=d.animationDelay,m=h[0]-j[0],n=h[1]-j[1],o=h[2]-j[2],p=h[3]-j[3],q=0,r=d.swingSpeed;c=k[0],e=k[1],f=k[2],g=k[3],bb.animMode(!0);var s,u=function(){return function(){q+=(100-q)/r,k[0]=Math.round(c+q/100*m),k[1]=Math.round(e+q/100*n),k[2]=Math.round(f+q/100*o),k[3]=Math.round(g+q/100*p),q>=99.8&&(q=100),q<100?(bh(k),t()):(bb.done(),bb.animMode(!1),typeof b=="function"&&b.call(bs))}}();t()}function bg(a){bh([a[0]/T,a[1]/U,a[2]/T,a[3]/U]),d.onSelect.call(bs,u(_.getFixed())),bb.enableHandles()}function bh(a){_.setPressed([a[0],a[1]]),_.setCurrent([a[2], 10 | a[3]]),bb.update()}function bi(){return u(_.getFixed())}function bj(){return _.getFixed()}function bk(a){n(a),br()}function bl(){d.disabled=!0,bb.disableHandles(),bb.setCursor("default"),bc.setCursor("default")}function bm(){d.disabled=!1,br()}function bn(){bb.done(),bc.activateHandlers(null,null)}function bo(){G.remove(),A.show(),A.css("visibility","visible"),a(b).removeData("Jcrop")}function bp(a,b){bb.release(),bl();var c=new Image;c.onload=function(){var e=c.width,f=c.height,g=d.boxWidth,h=d.boxHeight;D.width(e).height(f),D.attr("src",a),H.attr("src",a),t(D,g,h),E=D.width(),F=D.height(),H.width(E).height(F),M.width(E+L*2).height(F+L*2),G.width(E).height(F),ba.resize(E,F),bm(),typeof b=="function"&&b.call(bs)},c.src=a}function bq(a,b,c){var e=b||d.bgColor;d.bgFade&&k()&&d.fadeTime&&!c?a.animate({backgroundColor:e},{queue:!1,duration:d.fadeTime}):a.css("backgroundColor",e)}function br(a){d.allowResize?a?bb.enableOnly():bb.enableHandles():bb.disableHandles(),bc.setCursor(d.allowSelect?"crosshair":"default"),bb 11 | .setCursor(d.allowMove?"move":"default"),d.hasOwnProperty("trueSize")&&(T=d.trueSize[0]/E,U=d.trueSize[1]/F),d.hasOwnProperty("setSelect")&&(bg(d.setSelect),bb.done(),delete d.setSelect),ba.refresh(),d.bgColor!=N&&(bq(d.shade?ba.getShades():G,d.shade?d.shadeColor||d.bgColor:d.bgColor),N=d.bgColor),O!=d.bgOpacity&&(O=d.bgOpacity,d.shade?ba.refresh():bb.setBgOpacity(O)),P=d.maxSize[0]||0,Q=d.maxSize[1]||0,R=d.minSize[0]||0,S=d.minSize[1]||0,d.hasOwnProperty("outerImage")&&(D.attr("src",d.outerImage),delete d.outerImage),bb.refresh()}var d=a.extend({},a.Jcrop.defaults),e,f=navigator.userAgent.toLowerCase(),g=/msie/.test(f),h=/msie [1-6]\./.test(f);typeof b!="object"&&(b=a(b)[0]),typeof c!="object"&&(c={}),n(c);var z={border:"none",visibility:"visible",margin:0,padding:0,position:"absolute",top:0,left:0},A=a(b),B=!0;if(b.tagName=="IMG"){if(A[0].width!=0&&A[0].height!=0)A.width(A[0].width),A.height(A[0].height);else{var C=new Image;C.src=A[0].src,A.width(C.width),A.height(C.height)}var D=A.clone().removeAttr("id"). 12 | css(z).show();D.width(A.width()),D.height(A.height()),A.after(D).hide()}else D=A.css(z).show(),B=!1,d.shade===null&&(d.shade=!0);t(D,d.boxWidth,d.boxHeight);var E=D.width(),F=D.height(),G=a("
").width(E).height(F).addClass(j("holder")).css({position:"relative",backgroundColor:d.bgColor}).insertAfter(A).append(D);d.addClass&&G.addClass(d.addClass);var H=a("
"),I=a("
").width("100%").height("100%").css({zIndex:310,position:"absolute",overflow:"hidden"}),J=a("
").width("100%").height("100%").css("zIndex",320),K=a("
").css({position:"absolute",zIndex:600}).dblclick(function(){var a=_.getFixed();d.onDblClick.call(bs,a)}).insertBefore(D).append(I,J);B&&(H=a("").attr("src",D.attr("src")).css(z).width(E).height(F),I.append(H)),h&&K.css({overflowY:"hidden"});var L=d.boundary,M=y().width(E+L*2).height(F+L*2).css({position:"absolute",top:i(-L),left:i(-L),zIndex:290}).mousedown(w),N=d.bgColor,O=d.bgOpacity,P,Q,R,S,T,U,V=!0,W,X,Y;e=l(D);var Z=function(){function a(){var a={},b=["touchstart" 13 | ,"touchmove","touchend"],c=document.createElement("div"),d;try{for(d=0;da+f&&(f-=f+a),0>b+g&&(g-=g+b),FE&&(r=E,u=Math.abs((r-a)/f),s=k<0?b-u:u+b)):(r=c,u=l/f,s=k<0?b-u:b+u,s<0?(s=0,t=Math.abs((s-b)*f),r=j<0?a-t:t+a):s>F&&(s=F,t=Math.abs(s-b)*f,r=j<0?a-t:t+a)),r>a?(r-ah&&(r=a+h),s>b?s=b+(r-a)/f:s=b-(r-a)/f):rh&&(r=a-h),s>b?s=b+(a-r)/f:s=b-(a-r)/f),r<0?(a-=r,r=0):r>E&&(a-=r-E,r=E),s<0?(b-=s,s=0):s>F&&(b-=s-F,s=F),q(o(a,b,r,s))}function n(a){return a[0]<0&&(a[0]=0),a[1]<0&&(a[1]=0),a[0]>E&&(a[0]=E),a[1]>F&&(a[1]=F),[Math.round(a[0]),Math.round(a[1])]}function o(a,b,c,d){var e=a,f=c,g=b,h=d;return cP&&(c=d>0?a+P:a-P),Q&&Math.abs 15 | (f)>Q&&(e=f>0?b+Q:b-Q),S/U&&Math.abs(f)0?b+S/U:b-S/U),R/T&&Math.abs(d)0?a+R/T:a-R/T),a<0&&(c-=a,a-=a),b<0&&(e-=b,b-=b),c<0&&(a-=c,c-=c),e<0&&(b-=e,e-=e),c>E&&(g=c-E,a-=g,c-=g),e>F&&(g=e-F,b-=g,e-=g),a>E&&(g=a-F,e-=g,b-=g),b>F&&(g=b-F,e-=g,b-=g),q(o(a,b,c,e))}function q(a){return{x:a[0],y:a[1],x2:a[2],y2:a[3],w:a[2]-a[0],h:a[3]-a[1]}}var a=0,b=0,c=0,e=0,f,g;return{flipCoords:o,setPressed:h,setCurrent:i,getOffset:j,moveOffset:k,getCorner:l,getFixed:m}}(),ba=function(){function f(a,b){e.left.css({height:i(b)}),e.right.css({height:i(b)})}function g(){return h(_.getFixed())}function h(a){e.top.css({left:i(a.x),width:i(a.w),height:i(a.y)}),e.bottom.css({top:i(a.y2),left:i(a.x),width:i(a.w),height:i(F-a.y2)}),e.right.css({left:i(a.x2),width:i(E-a.x2)}),e.left.css({width:i(a.x)})}function j(){return a("
").css({position:"absolute",backgroundColor:d.shadeColor||d.bgColor}).appendTo(c)}function k(){b||(b=!0,c.insertBefore(D),g(),bb.setBgOpacity(1,0,1),H.hide(),l(d.shadeColor||d.bgColor,1),bb. 16 | isAwake()?n(d.bgOpacity,1):n(1,1))}function l(a,b){bq(p(),a,b)}function m(){b&&(c.remove(),H.show(),b=!1,bb.isAwake()?bb.setBgOpacity(d.bgOpacity,1,1):(bb.setBgOpacity(1,1,1),bb.disableHandles()),bq(G,0,1))}function n(a,e){b&&(d.bgFade&&!e?c.animate({opacity:1-a},{queue:!1,duration:d.fadeTime}):c.css({opacity:1-a}))}function o(){d.shade?k():m(),bb.isAwake()&&n(d.bgOpacity)}function p(){return c.children()}var b=!1,c=a("
").css({position:"absolute",zIndex:240,opacity:0}),e={top:j(),left:j().height(F),right:j().height(F),bottom:j()};return{update:g,updateRaw:h,getShades:p,setBgColor:l,enable:k,disable:m,resize:f,refresh:o,opacity:n}}(),bb=function(){function k(b){var c=a("
").css({position:"absolute",opacity:d.borderOpacity}).addClass(j(b));return I.append(c),c}function l(b,c){var d=a("
").mousedown(s(b)).css({cursor:b+"-resize",position:"absolute",zIndex:c}).addClass("ord-"+b);return Z.support&&d.bind("touchstart.jcrop",Z.createDragger(b)),J.append(d),d}function m(a){var b=d.handleSize,e=l(a,c++ 17 | ).css({opacity:d.handleOpacity}).addClass(j("handle"));return b&&e.width(b).height(b),e}function n(a){return l(a,c++).addClass("jcrop-dragbar")}function o(a){var b;for(b=0;b').css({position:"fixed",left:"-120px",width:"12px"}).addClass("jcrop-keymgr"),c=a("
").css({position:"absolute",overflow:"hidden"}).append(b);return d.keySupport&&(b.keydown(i).blur(f),h||!d.fixedSupport?(b.css({position:"absolute",left:"-20px"}),c.append(b).insertBefore(D)):b.insertBefore(D)),{watchKeys:e}}();Z.support&&M.bind("touchstart.jcrop",Z.newSelection),J.hide(),br(!0);var bs={setImage:bp,animateTo:bf,setSelect:bg,setOptions:bk,tellSelect:bi,tellScaled:bj,setClass:be,disable:bl,enable:bm,cancel:bn,release:bb.release,destroy:bo,focus:bd.watchKeys,getBounds:function(){return[E*T,F*U]},getWidgetSize:function(){return[E,F]},getScaleFactor:function(){return[T,U]},getOptions:function(){return d},ui:{holder:G,selection:K}};return g&&G.bind("selectstart",function(){return!1}),A.data("Jcrop",bs),bs},a.fn.Jcrop=function(b,c){var d;return this.each(function(){if(a(this).data("Jcrop")){if( 21 | b==="api")return a(this).data("Jcrop");a(this).data("Jcrop").setOptions(b)}else this.tagName=="IMG"?a.Jcrop.Loader(this,function(){a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d)}):(a(this).css({display:"block",visibility:"hidden"}),d=a.Jcrop(this,b),a.isFunction(c)&&c.call(d))}),this},a.Jcrop.Loader=function(b,c,d){function g(){f.complete?(e.unbind(".jcloader"),a.isFunction(c)&&c.call(f)):window.setTimeout(g,50)}var e=a(b),f=e[0];e.bind("load.jcloader",g).bind("error.jcloader",function(b){e.unbind(".jcloader"),a.isFunction(d)&&d.call(f)}),f.complete&&a.isFunction(c)&&(e.unbind(".jcloader"),c.call(f))},a.Jcrop.defaults={allowSelect:!0,allowMove:!0,allowResize:!0,trackDocument:!0,baseClass:"jcrop",addClass:null,bgColor:"black",bgOpacity:.6,bgFade:!1,borderOpacity:.4,handleOpacity:.5,handleSize:null,aspectRatio:0,keySupport:!0,createHandles:["n","s","e","w","nw","ne","se","sw"],createDragbars:["n","s","e","w"],createBorders:["n","s","e","w"],drawBorders:!0,dragEdges 22 | :!0,fixedSupport:!0,touchSupport:null,shade:null,boxWidth:0,boxHeight:0,boundary:2,fadeTime:400,animationDelay:20,swingSpeed:3,minSelect:[0,0],maxSize:[0,0],minSize:[0,0],onChange:function(){},onSelect:function(){},onDblClick:function(){},onRelease:function(){}}})(jQuery); -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/jquery.color.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery Color Animations v2.0pre 3 | * http://jquery.org/ 4 | * 5 | * Copyright 2011 John Resig 6 | * Dual licensed under the MIT or GPL Version 2 licenses. 7 | * http://jquery.org/license 8 | */ 9 | 10 | (function( jQuery, undefined ){ 11 | var stepHooks = "backgroundColor borderBottomColor borderLeftColor borderRightColor borderTopColor color outlineColor".split(" "), 12 | 13 | // plusequals test for += 100 -= 100 14 | rplusequals = /^([\-+])=\s*(\d+\.?\d*)/, 15 | // a set of RE's that can match strings and generate color tuples. 16 | stringParsers = [{ 17 | re: /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/, 18 | parse: function( execResult ) { 19 | return [ 20 | execResult[ 1 ], 21 | execResult[ 2 ], 22 | execResult[ 3 ], 23 | execResult[ 4 ] 24 | ]; 25 | } 26 | }, { 27 | re: /rgba?\(\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/, 28 | parse: function( execResult ) { 29 | return [ 30 | 2.55 * execResult[1], 31 | 2.55 * execResult[2], 32 | 2.55 * execResult[3], 33 | execResult[ 4 ] 34 | ]; 35 | } 36 | }, { 37 | re: /#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/, 38 | parse: function( execResult ) { 39 | return [ 40 | parseInt( execResult[ 1 ], 16 ), 41 | parseInt( execResult[ 2 ], 16 ), 42 | parseInt( execResult[ 3 ], 16 ) 43 | ]; 44 | } 45 | }, { 46 | re: /#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/, 47 | parse: function( execResult ) { 48 | return [ 49 | parseInt( execResult[ 1 ] + execResult[ 1 ], 16 ), 50 | parseInt( execResult[ 2 ] + execResult[ 2 ], 16 ), 51 | parseInt( execResult[ 3 ] + execResult[ 3 ], 16 ) 52 | ]; 53 | } 54 | }, { 55 | re: /hsla?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\%\s*,\s*(\d+(?:\.\d+)?)\%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)/, 56 | space: "hsla", 57 | parse: function( execResult ) { 58 | return [ 59 | execResult[1], 60 | execResult[2] / 100, 61 | execResult[3] / 100, 62 | execResult[4] 63 | ]; 64 | } 65 | }], 66 | 67 | // jQuery.Color( ) 68 | color = jQuery.Color = function( color, green, blue, alpha ) { 69 | return new jQuery.Color.fn.parse( color, green, blue, alpha ); 70 | }, 71 | spaces = { 72 | rgba: { 73 | cache: "_rgba", 74 | props: { 75 | red: { 76 | idx: 0, 77 | type: "byte", 78 | empty: true 79 | }, 80 | green: { 81 | idx: 1, 82 | type: "byte", 83 | empty: true 84 | }, 85 | blue: { 86 | idx: 2, 87 | type: "byte", 88 | empty: true 89 | }, 90 | alpha: { 91 | idx: 3, 92 | type: "percent", 93 | def: 1 94 | } 95 | } 96 | }, 97 | hsla: { 98 | cache: "_hsla", 99 | props: { 100 | hue: { 101 | idx: 0, 102 | type: "degrees", 103 | empty: true 104 | }, 105 | saturation: { 106 | idx: 1, 107 | type: "percent", 108 | empty: true 109 | }, 110 | lightness: { 111 | idx: 2, 112 | type: "percent", 113 | empty: true 114 | } 115 | } 116 | } 117 | }, 118 | propTypes = { 119 | "byte": { 120 | floor: true, 121 | min: 0, 122 | max: 255 123 | }, 124 | "percent": { 125 | min: 0, 126 | max: 1 127 | }, 128 | "degrees": { 129 | mod: 360, 130 | floor: true 131 | } 132 | }, 133 | rgbaspace = spaces.rgba.props, 134 | support = color.support = {}, 135 | 136 | // colors = jQuery.Color.names 137 | colors, 138 | 139 | // local aliases of functions called often 140 | each = jQuery.each; 141 | 142 | spaces.hsla.props.alpha = rgbaspace.alpha; 143 | 144 | function clamp( value, prop, alwaysAllowEmpty ) { 145 | var type = propTypes[ prop.type ] || {}, 146 | allowEmpty = prop.empty || alwaysAllowEmpty; 147 | 148 | if ( allowEmpty && value == null ) { 149 | return null; 150 | } 151 | if ( prop.def && value == null ) { 152 | return prop.def; 153 | } 154 | if ( type.floor ) { 155 | value = ~~value; 156 | } else { 157 | value = parseFloat( value ); 158 | } 159 | if ( value == null || isNaN( value ) ) { 160 | return prop.def; 161 | } 162 | if ( type.mod ) { 163 | value = value % type.mod; 164 | // -10 -> 350 165 | return value < 0 ? type.mod + value : value; 166 | } 167 | 168 | // for now all property types without mod have min and max 169 | return type.min > value ? type.min : type.max < value ? type.max : value; 170 | } 171 | 172 | function stringParse( string ) { 173 | var inst = color(), 174 | rgba = inst._rgba = []; 175 | 176 | string = string.toLowerCase(); 177 | 178 | each( stringParsers, function( i, parser ) { 179 | var match = parser.re.exec( string ), 180 | values = match && parser.parse( match ), 181 | parsed, 182 | spaceName = parser.space || "rgba", 183 | cache = spaces[ spaceName ].cache; 184 | 185 | 186 | if ( values ) { 187 | parsed = inst[ spaceName ]( values ); 188 | 189 | // if this was an rgba parse the assignment might happen twice 190 | // oh well.... 191 | inst[ cache ] = parsed[ cache ]; 192 | rgba = inst._rgba = parsed._rgba; 193 | 194 | // exit each( stringParsers ) here because we matched 195 | return false; 196 | } 197 | }); 198 | 199 | // Found a stringParser that handled it 200 | if ( rgba.length !== 0 ) { 201 | 202 | // if this came from a parsed string, force "transparent" when alpha is 0 203 | // chrome, (and maybe others) return "transparent" as rgba(0,0,0,0) 204 | if ( Math.max.apply( Math, rgba ) === 0 ) { 205 | jQuery.extend( rgba, colors.transparent ); 206 | } 207 | return inst; 208 | } 209 | 210 | // named colors / default - filter back through parse function 211 | if ( string = colors[ string ] ) { 212 | return string; 213 | } 214 | } 215 | 216 | color.fn = color.prototype = { 217 | constructor: color, 218 | parse: function( red, green, blue, alpha ) { 219 | if ( red === undefined ) { 220 | this._rgba = [ null, null, null, null ]; 221 | return this; 222 | } 223 | if ( red instanceof jQuery || red.nodeType ) { 224 | red = red instanceof jQuery ? red.css( green ) : jQuery( red ).css( green ); 225 | green = undefined; 226 | } 227 | 228 | var inst = this, 229 | type = jQuery.type( red ), 230 | rgba = this._rgba = [], 231 | source; 232 | 233 | // more than 1 argument specified - assume ( red, green, blue, alpha ) 234 | if ( green !== undefined ) { 235 | red = [ red, green, blue, alpha ]; 236 | type = "array"; 237 | } 238 | 239 | if ( type === "string" ) { 240 | return this.parse( stringParse( red ) || colors._default ); 241 | } 242 | 243 | if ( type === "array" ) { 244 | each( rgbaspace, function( key, prop ) { 245 | rgba[ prop.idx ] = clamp( red[ prop.idx ], prop ); 246 | }); 247 | return this; 248 | } 249 | 250 | if ( type === "object" ) { 251 | if ( red instanceof color ) { 252 | each( spaces, function( spaceName, space ) { 253 | if ( red[ space.cache ] ) { 254 | inst[ space.cache ] = red[ space.cache ].slice(); 255 | } 256 | }); 257 | } else { 258 | each( spaces, function( spaceName, space ) { 259 | each( space.props, function( key, prop ) { 260 | var cache = space.cache; 261 | 262 | // if the cache doesn't exist, and we know how to convert 263 | if ( !inst[ cache ] && space.to ) { 264 | 265 | // if the value was null, we don't need to copy it 266 | // if the key was alpha, we don't need to copy it either 267 | if ( red[ key ] == null || key === "alpha") { 268 | return; 269 | } 270 | inst[ cache ] = space.to( inst._rgba ); 271 | } 272 | 273 | // this is the only case where we allow nulls for ALL properties. 274 | // call clamp with alwaysAllowEmpty 275 | inst[ cache ][ prop.idx ] = clamp( red[ key ], prop, true ); 276 | }); 277 | }); 278 | } 279 | return this; 280 | } 281 | }, 282 | is: function( compare ) { 283 | var is = color( compare ), 284 | same = true, 285 | myself = this; 286 | 287 | each( spaces, function( _, space ) { 288 | var isCache = is[ space.cache ], 289 | localCache; 290 | if (isCache) { 291 | localCache = myself[ space.cache ] || space.to && space.to( myself._rgba ) || []; 292 | each( space.props, function( _, prop ) { 293 | if ( isCache[ prop.idx ] != null ) { 294 | same = ( isCache[ prop.idx ] === localCache[ prop.idx ] ); 295 | return same; 296 | } 297 | }); 298 | } 299 | return same; 300 | }); 301 | return same; 302 | }, 303 | _space: function() { 304 | var used = [], 305 | inst = this; 306 | each( spaces, function( spaceName, space ) { 307 | if ( inst[ space.cache ] ) { 308 | used.push( spaceName ); 309 | } 310 | }); 311 | return used.pop(); 312 | }, 313 | transition: function( other, distance ) { 314 | var end = color( other ), 315 | spaceName = end._space(), 316 | space = spaces[ spaceName ], 317 | start = this[ space.cache ] || space.to( this._rgba ), 318 | result = start.slice(); 319 | 320 | end = end[ space.cache ]; 321 | each( space.props, function( key, prop ) { 322 | var index = prop.idx, 323 | startValue = start[ index ], 324 | endValue = end[ index ], 325 | type = propTypes[ prop.type ] || {}; 326 | 327 | // if null, don't override start value 328 | if ( endValue === null ) { 329 | return; 330 | } 331 | // if null - use end 332 | if ( startValue === null ) { 333 | result[ index ] = endValue; 334 | } else { 335 | if ( type.mod ) { 336 | if ( endValue - startValue > type.mod / 2 ) { 337 | startValue += type.mod; 338 | } else if ( startValue - endValue > type.mod / 2 ) { 339 | startValue -= type.mod; 340 | } 341 | } 342 | result[ prop.idx ] = clamp( ( endValue - startValue ) * distance + startValue, prop ); 343 | } 344 | }); 345 | return this[ spaceName ]( result ); 346 | }, 347 | blend: function( opaque ) { 348 | // if we are already opaque - return ourself 349 | if ( this._rgba[ 3 ] === 1 ) { 350 | return this; 351 | } 352 | 353 | var rgb = this._rgba.slice(), 354 | a = rgb.pop(), 355 | blend = color( opaque )._rgba; 356 | 357 | return color( jQuery.map( rgb, function( v, i ) { 358 | return ( 1 - a ) * blend[ i ] + a * v; 359 | })); 360 | }, 361 | toRgbaString: function() { 362 | var prefix = "rgba(", 363 | rgba = jQuery.map( this._rgba, function( v, i ) { 364 | return v == null ? ( i > 2 ? 1 : 0 ) : v; 365 | }); 366 | 367 | if ( rgba[ 3 ] === 1 ) { 368 | rgba.pop(); 369 | prefix = "rgb("; 370 | } 371 | 372 | return prefix + rgba.join(",") + ")"; 373 | }, 374 | toHslaString: function() { 375 | var prefix = "hsla(", 376 | hsla = jQuery.map( this.hsla(), function( v, i ) { 377 | if ( v == null ) { 378 | v = i > 2 ? 1 : 0; 379 | } 380 | 381 | // catch 1 and 2 382 | if ( i && i < 3 ) { 383 | v = Math.round( v * 100 ) + "%"; 384 | } 385 | return v; 386 | }); 387 | 388 | if ( hsla[ 3 ] === 1 ) { 389 | hsla.pop(); 390 | prefix = "hsl("; 391 | } 392 | return prefix + hsla.join(",") + ")"; 393 | }, 394 | toHexString: function( includeAlpha ) { 395 | var rgba = this._rgba.slice(), 396 | alpha = rgba.pop(); 397 | 398 | if ( includeAlpha ) { 399 | rgba.push( ~~( alpha * 255 ) ); 400 | } 401 | 402 | return "#" + jQuery.map( rgba, function( v, i ) { 403 | 404 | // default to 0 when nulls exist 405 | v = ( v || 0 ).toString( 16 ); 406 | return v.length === 1 ? "0" + v : v; 407 | }).join(""); 408 | }, 409 | toString: function() { 410 | return this._rgba[ 3 ] === 0 ? "transparent" : this.toRgbaString(); 411 | } 412 | }; 413 | color.fn.parse.prototype = color.fn; 414 | 415 | // hsla conversions adapted from: 416 | // http://www.google.com/codesearch/p#OAMlx_jo-ck/src/third_party/WebKit/Source/WebCore/inspector/front-end/Color.js&d=7&l=193 417 | 418 | function hue2rgb( p, q, h ) { 419 | h = ( h + 1 ) % 1; 420 | if ( h * 6 < 1 ) { 421 | return p + (q - p) * 6 * h; 422 | } 423 | if ( h * 2 < 1) { 424 | return q; 425 | } 426 | if ( h * 3 < 2 ) { 427 | return p + (q - p) * ((2/3) - h) * 6; 428 | } 429 | return p; 430 | } 431 | 432 | spaces.hsla.to = function ( rgba ) { 433 | if ( rgba[ 0 ] == null || rgba[ 1 ] == null || rgba[ 2 ] == null ) { 434 | return [ null, null, null, rgba[ 3 ] ]; 435 | } 436 | var r = rgba[ 0 ] / 255, 437 | g = rgba[ 1 ] / 255, 438 | b = rgba[ 2 ] / 255, 439 | a = rgba[ 3 ], 440 | max = Math.max( r, g, b ), 441 | min = Math.min( r, g, b ), 442 | diff = max - min, 443 | add = max + min, 444 | l = add * 0.5, 445 | h, s; 446 | 447 | if ( min === max ) { 448 | h = 0; 449 | } else if ( r === max ) { 450 | h = ( 60 * ( g - b ) / diff ) + 360; 451 | } else if ( g === max ) { 452 | h = ( 60 * ( b - r ) / diff ) + 120; 453 | } else { 454 | h = ( 60 * ( r - g ) / diff ) + 240; 455 | } 456 | 457 | if ( l === 0 || l === 1 ) { 458 | s = l; 459 | } else if ( l <= 0.5 ) { 460 | s = diff / add; 461 | } else { 462 | s = diff / ( 2 - add ); 463 | } 464 | return [ Math.round(h) % 360, s, l, a == null ? 1 : a ]; 465 | }; 466 | 467 | spaces.hsla.from = function ( hsla ) { 468 | if ( hsla[ 0 ] == null || hsla[ 1 ] == null || hsla[ 2 ] == null ) { 469 | return [ null, null, null, hsla[ 3 ] ]; 470 | } 471 | var h = hsla[ 0 ] / 360, 472 | s = hsla[ 1 ], 473 | l = hsla[ 2 ], 474 | a = hsla[ 3 ], 475 | q = l <= 0.5 ? l * ( 1 + s ) : l + s - l * s, 476 | p = 2 * l - q, 477 | r, g, b; 478 | 479 | return [ 480 | Math.round( hue2rgb( p, q, h + ( 1 / 3 ) ) * 255 ), 481 | Math.round( hue2rgb( p, q, h ) * 255 ), 482 | Math.round( hue2rgb( p, q, h - ( 1 / 3 ) ) * 255 ), 483 | a 484 | ]; 485 | }; 486 | 487 | 488 | each( spaces, function( spaceName, space ) { 489 | var props = space.props, 490 | cache = space.cache, 491 | to = space.to, 492 | from = space.from; 493 | 494 | // makes rgba() and hsla() 495 | color.fn[ spaceName ] = function( value ) { 496 | 497 | // generate a cache for this space if it doesn't exist 498 | if ( to && !this[ cache ] ) { 499 | this[ cache ] = to( this._rgba ); 500 | } 501 | if ( value === undefined ) { 502 | return this[ cache ].slice(); 503 | } 504 | 505 | var type = jQuery.type( value ), 506 | arr = ( type === "array" || type === "object" ) ? value : arguments, 507 | local = this[ cache ].slice(), 508 | ret; 509 | 510 | each( props, function( key, prop ) { 511 | var val = arr[ type === "object" ? key : prop.idx ]; 512 | if ( val == null ) { 513 | val = local[ prop.idx ]; 514 | } 515 | local[ prop.idx ] = clamp( val, prop ); 516 | }); 517 | 518 | if ( from ) { 519 | ret = color( from( local ) ); 520 | ret[ cache ] = local; 521 | return ret; 522 | } else { 523 | return color( local ); 524 | } 525 | }; 526 | 527 | // makes red() green() blue() alpha() hue() saturation() lightness() 528 | each( props, function( key, prop ) { 529 | // alpha is included in more than one space 530 | if ( color.fn[ key ] ) { 531 | return; 532 | } 533 | color.fn[ key ] = function( value ) { 534 | var vtype = jQuery.type( value ), 535 | fn = ( key === 'alpha' ? ( this._hsla ? 'hsla' : 'rgba' ) : spaceName ), 536 | local = this[ fn ](), 537 | cur = local[ prop.idx ], 538 | match; 539 | 540 | if ( vtype === "undefined" ) { 541 | return cur; 542 | } 543 | 544 | if ( vtype === "function" ) { 545 | value = value.call( this, cur ); 546 | vtype = jQuery.type( value ); 547 | } 548 | if ( value == null && prop.empty ) { 549 | return this; 550 | } 551 | if ( vtype === "string" ) { 552 | match = rplusequals.exec( value ); 553 | if ( match ) { 554 | value = cur + parseFloat( match[ 2 ] ) * ( match[ 1 ] === "+" ? 1 : -1 ); 555 | } 556 | } 557 | local[ prop.idx ] = value; 558 | return this[ fn ]( local ); 559 | }; 560 | }); 561 | }); 562 | 563 | // add .fx.step functions 564 | each( stepHooks, function( i, hook ) { 565 | jQuery.cssHooks[ hook ] = { 566 | set: function( elem, value ) { 567 | var parsed, backgroundColor, curElem; 568 | 569 | if ( jQuery.type( value ) !== 'string' || ( parsed = stringParse( value ) ) ) 570 | { 571 | value = color( parsed || value ); 572 | if ( !support.rgba && value._rgba[ 3 ] !== 1 ) { 573 | curElem = hook === "backgroundColor" ? elem.parentNode : elem; 574 | do { 575 | backgroundColor = jQuery.curCSS( curElem, "backgroundColor" ); 576 | } while ( 577 | ( backgroundColor === "" || backgroundColor === "transparent" ) && 578 | ( curElem = curElem.parentNode ) && 579 | curElem.style 580 | ); 581 | 582 | value = value.blend( backgroundColor && backgroundColor !== "transparent" ? 583 | backgroundColor : 584 | "_default" ); 585 | } 586 | 587 | value = value.toRgbaString(); 588 | } 589 | elem.style[ hook ] = value; 590 | } 591 | }; 592 | jQuery.fx.step[ hook ] = function( fx ) { 593 | if ( !fx.colorInit ) { 594 | fx.start = color( fx.elem, hook ); 595 | fx.end = color( fx.end ); 596 | fx.colorInit = true; 597 | } 598 | jQuery.cssHooks[ hook ].set( fx.elem, fx.start.transition( fx.end, fx.pos ) ); 599 | }; 600 | }); 601 | 602 | // detect rgba support 603 | jQuery(function() { 604 | var div = document.createElement( "div" ), 605 | div_style = div.style; 606 | 607 | div_style.cssText = "background-color:rgba(1,1,1,.5)"; 608 | support.rgba = div_style.backgroundColor.indexOf( "rgba" ) > -1; 609 | }); 610 | 611 | // Some named colors to work with 612 | // From Interface by Stefan Petre 613 | // http://interface.eyecon.ro/ 614 | colors = jQuery.Color.names = { 615 | aqua: "#00ffff", 616 | azure: "#f0ffff", 617 | beige: "#f5f5dc", 618 | black: "#000000", 619 | blue: "#0000ff", 620 | brown: "#a52a2a", 621 | cyan: "#00ffff", 622 | darkblue: "#00008b", 623 | darkcyan: "#008b8b", 624 | darkgrey: "#a9a9a9", 625 | darkgreen: "#006400", 626 | darkkhaki: "#bdb76b", 627 | darkmagenta: "#8b008b", 628 | darkolivegreen: "#556b2f", 629 | darkorange: "#ff8c00", 630 | darkorchid: "#9932cc", 631 | darkred: "#8b0000", 632 | darksalmon: "#e9967a", 633 | darkviolet: "#9400d3", 634 | fuchsia: "#ff00ff", 635 | gold: "#ffd700", 636 | green: "#008000", 637 | indigo: "#4b0082", 638 | khaki: "#f0e68c", 639 | lightblue: "#add8e6", 640 | lightcyan: "#e0ffff", 641 | lightgreen: "#90ee90", 642 | lightgrey: "#d3d3d3", 643 | lightpink: "#ffb6c1", 644 | lightyellow: "#ffffe0", 645 | lime: "#00ff00", 646 | magenta: "#ff00ff", 647 | maroon: "#800000", 648 | navy: "#000080", 649 | olive: "#808000", 650 | orange: "#ffa500", 651 | pink: "#ffc0cb", 652 | purple: "#800080", 653 | violet: "#800080", 654 | red: "#ff0000", 655 | silver: "#c0c0c0", 656 | white: "#ffffff", 657 | yellow: "#ffff00", 658 | transparent: [ null, null, null, 0 ], 659 | _default: "#ffffff" 660 | }; 661 | })( jQuery ); 662 | -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/manual-trigger.js: -------------------------------------------------------------------------------- 1 | /* 2 | -------------------------------- 3 | Infinite Scroll Behavior 4 | Manual / Twitter-style 5 | -------------------------------- 6 | + https://github.com/paulirish/infinitescroll/ 7 | + version 2.0b2.110617 8 | + Copyright 2011 Paul Irish & Luke Shumard 9 | + Licensed under the MIT license 10 | 11 | + Documentation: http://infinite-scroll.com/ 12 | 13 | */ 14 | 15 | (function($, undefined) { 16 | $.extend($.infinitescroll.prototype,{ 17 | 18 | _setup_twitter: function infscr_setup_twitter () { 19 | var opts = this.options, 20 | instance = this; 21 | 22 | // Bind nextSelector link to retrieve 23 | $(opts.nextSelector).click(function(e) { 24 | if (e.which == 1 && !e.metaKey && !e.shiftKey) { 25 | e.preventDefault(); 26 | instance.retrieve(); 27 | } 28 | }); 29 | 30 | // Define loadingStart to never hide pager 31 | instance.options.loading.start = function (opts) { 32 | opts.loading.msg 33 | .appendTo(opts.loading.selector) 34 | .show(opts.loading.speed, function () { 35 | instance.beginAjax(opts); 36 | }); 37 | } 38 | }, 39 | _showdonemsg_twitter: function infscr_showdonemsg_twitter () { 40 | var opts = this.options, 41 | instance = this; 42 | 43 | //Do all the usual stuff 44 | opts.loading.msg 45 | .find('img') 46 | .hide() 47 | .parent() 48 | .find('div').html(opts.loading.finishedMsg).animate({ opacity: 1 }, 2000, function () { 49 | $(this).parent().fadeOut('normal'); 50 | }); 51 | 52 | //And also hide the navSelector 53 | $(opts.navSelector).fadeOut('normal'); 54 | 55 | // user provided callback when done 56 | opts.errorCallback.call($(opts.contentSelector)[0],'done'); 57 | 58 | } 59 | 60 | }); 61 | })(jQuery); 62 | -------------------------------------------------------------------------------- /betty/image_browser/static/betty/js/upload.js: -------------------------------------------------------------------------------- 1 | function showMessage(message, messageClass) { 2 | message = message || 'An unknown error occurred'; 3 | messageClass = messageClass || 'danger'; 4 | var error = $('
' + message + '
'); 5 | var x = $(''); 6 | error.append(x); 7 | 8 | $('.modal-body').prepend(error); 9 | error.slideDown(); 10 | var errorTimeout = window.setTimeout(function(){ 11 | error.slideUp(function(){$(this).remove();}) 12 | }, 5000); 13 | 14 | x.click(function(){ 15 | window.clearTimeout(errorTimeout); 16 | error.slideUp(function(){$(this).remove();}) 17 | }); 18 | console.log(message); 19 | } 20 | 21 | function processFile(file){ 22 | var previewEl = $('.preview')[0]; 23 | previewEl.onload = function(e){ 24 | $('.upload-form').hide(); 25 | $('.image-form').show(); 26 | $('.name-input').focus(); 27 | 28 | if(this.width < 1000) { 29 | showMessage('Small image! This image is pretty small, and may look pixelated.'); 30 | } 31 | 32 | var name = file.name.split('.')[0]; 33 | 34 | $('.name-input').val(name); 35 | $('.upload-button').removeAttr('disabled'); 36 | } 37 | previewEl.onerror = function(){ 38 | console.log('Error!'); 39 | $('.image-form').hide(); 40 | $('.upload-form').show(); 41 | 42 | showMessage('Whoops! It looks like that isn\'t a valid image.'); 43 | } 44 | var reader = new FileReader(); 45 | reader.onload = function(e){ 46 | previewEl.src = e.target.result; 47 | }; 48 | reader.readAsDataURL(file); 49 | } 50 | 51 | 52 | function initUploadModal(el){ 53 | $('#upload-image').submit(function(e){ 54 | e.preventDefault(); 55 | 56 | var name = $('#upload-image .name input').val(); 57 | var credit = $('#upload-image .credit input').val(); 58 | if(name == '') { 59 | $('#upload-image .name').addClass('has-error'); 60 | } 61 | 62 | var data = new FormData(); 63 | var file = $('.image-picker')[0].files[0]; 64 | data.append('image', file); 65 | data.append('name', name); 66 | if (credit !== '') { 67 | data.append('credit', credit); 68 | } 69 | $.ajax({ 70 | url: this.action, 71 | type: 'POST', 72 | data: data, 73 | processData: false, 74 | contentType: false, 75 | success: function(data, textStatus, xhr){ 76 | $('#upload-modal').modal('hide'); 77 | window.location.reload(); 78 | } 79 | }); 80 | }); 81 | $('.upload-well').click(function(){ 82 | $('.image-picker').click(); 83 | }); 84 | $('.image-picker').change(function(){ 85 | if (this.files.length == 1) { 86 | var file = this.files[0]; 87 | processFile(file); 88 | } 89 | }); 90 | } 91 | 92 | $('#upload-modal').on('show.bs.modal', function (e) { 93 | $('.alert').alert('close'); // hide the alerts on modal open 94 | }) 95 | 96 | function clearUploadModal(el) { 97 | $(el).find('.image-form').hide(); 98 | $(el).find('.upload-form').show(); 99 | 100 | var previewEl = $('.preview')[0]; 101 | previewEl.src = null; 102 | } -------------------------------------------------------------------------------- /betty/image_browser/templates/crop.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /betty/image_browser/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Betty Cropper 8 | 9 | 10 | 11 | 12 |
13 | 14 | 63 |
64 | 65 |
    66 | 67 | {% for image in images %} 68 |
  • 69 | 70 |
    71 | 72 | testing 73 | 74 |
    75 | 76 |
    77 |
    78 | 79 |
    Name:
    80 |
    {{ image.name }}
    81 | 82 |
    Credit:
    83 |
    {{ image.credit }}
    84 | 85 |
    Size:
    86 |
    {{ image.width }} x {{ image.height }}
    87 | 88 |
    Added:
    89 |
    xxx
    90 | 91 |
    Uploader:
    92 |
    xxx
    93 | 94 |
    95 |
    96 |
  • 97 | {% endfor %} 98 | 99 |
100 | 101 |

102 | More images 103 |

104 | 105 | 116 | 117 |
118 | 119 | 120 | 126 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /betty/image_browser/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Betty Cropper 8 | 9 | 10 | 11 | 16 |
17 |
18 |
19 |
{% csrf_token %} 20 |
21 | {% for error in form.username.errors %} 22 |
{{ error }}
23 | {% endfor %} 24 | 25 |
26 | 27 |
28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 | 45 |
46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /betty/image_browser/templates/upload.html: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 32 | 36 | 37 |
-------------------------------------------------------------------------------- /betty/image_browser/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, url 2 | 3 | urlpatterns = patterns('betty.image_browser.views', 4 | url(r'^search.html$', 'search'), # noqa 5 | url(r'^upload\.html$', 'upload'), 6 | url(r'^crop\.html$', 'crop'), 7 | ) 8 | -------------------------------------------------------------------------------- /betty/image_browser/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage 3 | from django.shortcuts import render 4 | 5 | from betty.cropper.models import Image 6 | 7 | 8 | SIZE_MAP = { 9 | "large": { 10 | "width__gte": 1024, 11 | }, 12 | "medium": { 13 | "width__gte": 400, 14 | "width__lt": 1024 15 | }, 16 | "small": { 17 | "width__lt": 400 18 | } 19 | } 20 | 21 | 22 | @login_required 23 | def search(request): 24 | queryset = Image.objects.all().order_by('-id') 25 | if request.GET.get("size", "all") in SIZE_MAP: 26 | queryset = queryset.filter(**SIZE_MAP[request.GET["size"]]) 27 | if request.GET.get("q", "") != "": 28 | queryset = queryset.filter(name__icontains=request.GET.get("q")) 29 | 30 | paginator = Paginator(queryset, 24) 31 | page = request.GET.get('page') 32 | try: 33 | images = paginator.page(page) 34 | except PageNotAnInteger: 35 | images = paginator.page(1) 36 | except EmptyPage: 37 | images = paginator.page(paginator.num_pages) 38 | 39 | context = { 40 | "images": images, 41 | "q": request.GET.get("q") 42 | } 43 | return render(request, "index.html", context) 44 | 45 | 46 | @login_required 47 | def upload(request): 48 | return render(request, "upload.html", {}) 49 | 50 | 51 | @login_required 52 | def crop(request): 53 | height = 100 54 | ratios = [ 55 | { 56 | "name": "3x1", 57 | "width": 3 * height / 1, 58 | "height": height 59 | }, 60 | { 61 | "name": "2x1", 62 | "width": 2 * height / 1, 63 | "height": height 64 | }, 65 | { 66 | "name": "16x9", 67 | "width": 16 * height / 9, 68 | "height": height 69 | }, 70 | { 71 | "name": "4x3", 72 | "width": 4 * height / 3, 73 | "height": height 74 | }, 75 | { 76 | "name": "1x1", 77 | "width": 1 * height / 1, 78 | "height": height 79 | }, 80 | { 81 | "name": "3x4", 82 | "width": 3 * height / 4, 83 | "height": height 84 | }, 85 | ] 86 | return render(request, "crop.html", {"ratios": ratios}) 87 | -------------------------------------------------------------------------------- /betty/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import sys 4 | 5 | # Add the project to the python path 6 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir)) 7 | sys.stdout = sys.stderr 8 | 9 | # Configure the application (Logan) 10 | from betty.cropper.utils.runner import configure # NOQA 11 | configure() 12 | 13 | # Build the wsgi app 14 | import django.core.wsgi # NOQA 15 | 16 | # Run WSGI handler for the application 17 | application = django.core.wsgi.get_wsgi_application() 18 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | 3 | import pytest 4 | from logan.runner import configure_app 5 | 6 | 7 | configure_app( 8 | project="betty", 9 | default_settings="betty.conf.server", 10 | config_path="./tests/betty.testconf.py" 11 | ) 12 | 13 | 14 | @pytest.fixture() 15 | def clean_image_root(request): 16 | """Delete all image files created during testing""" 17 | 18 | from betty.conf.app import settings 19 | shutil.rmtree(settings.BETTY_IMAGE_ROOT, ignore_errors=True) 20 | 21 | 22 | @pytest.fixture() 23 | def clear_cache(request): 24 | """Clear test cache between runs""" 25 | from django.core.cache import cache 26 | cache.clear() 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | web: 2 | build: . 3 | # FROM https://github.com/unbit/uwsgi-docker 4 | # in bridged mode the class B network is allocated, so we can simply bind to the first address starting with 172 5 | # so we use the very handy .* trick 6 | # NOTE: If uWSGI "--python-autoreload" doesn't work / is too slow, could try Django handler: http://serverfault.com/a/642024 7 | command: > 8 | uwsgi 9 | --socket "172.*:8000" 10 | --subscribe-to "$$(FASTROUTER_PORT_8000_TCP_ADDR):$$(FASTROUTER_PORT_8000_TCP_PORT):betty.local" 11 | --workers 1 12 | --buffer-size 16384 13 | --disable-sendfile 14 | --python-autoreload 3 15 | --honour-stdin 16 | stdin_open: true 17 | volumes: 18 | - ./:/webapp/ 19 | ## Optional dotfiles 20 | #- ~/.bash_history:/root/.bash_history 21 | #- ~/.inputrc:/root/.inputrc:ro 22 | #- ~/.ipython:/root/.ipython 23 | external_links: 24 | - onionservices_fastrouter_1:fastrouter 25 | hostname: betty 26 | domainname: local 27 | -------------------------------------------------------------------------------- /requirements/common.txt: -------------------------------------------------------------------------------- 1 | Django>=1.7,<1.9 2 | six==1.9.0 3 | slimit==0.8.1 4 | jsonfield==0.9.20 5 | Pillow==2.5.3 6 | South==0.8.4 7 | logan==0.6.0 8 | celery==3.1.11 9 | requests>=2.11.0 10 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | flake8<3 2 | httmock==1.2.5 3 | pytest==2.9.0 4 | pytest-django==2.8.0 5 | pytest-cov>=1.4 6 | coveralls==0.4.1 7 | mock>=1.0.1 8 | psutil==2.2.1 9 | dj-inmemorystorage==1.4.0 10 | ipython==4.1.1 11 | ipdb==0.8.1 12 | freezegun==0.3.7 13 | -------------------------------------------------------------------------------- /requirements/imgmin.txt: -------------------------------------------------------------------------------- 1 | numpy==1.11.0 2 | scipy==0.17.0 3 | -------------------------------------------------------------------------------- /requirements/s3.txt: -------------------------------------------------------------------------------- 1 | boto==2.39.0 2 | django-storages==1.4 3 | -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh -euo pipefail 2 | # 3 | # Build Docker image 4 | 5 | docker-compose build "$@" 6 | -------------------------------------------------------------------------------- /scripts/clean: -------------------------------------------------------------------------------- 1 | #!/bin/sh -euo pipefail 2 | # 3 | # Cleanup local development environment 4 | 5 | # Delete local SQLite database 6 | rm -f betty.db 7 | 8 | # Delete image directory 9 | rm -rf images/ 10 | -------------------------------------------------------------------------------- /scripts/init: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Install + update project environment 4 | 5 | SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 6 | 7 | $SCRIPT_DIR/clean 8 | $SCRIPT_DIR/install 9 | 10 | docker-compose run web betty-cropper syncdb 11 | docker-compose run web betty-cropper migrate 12 | docker-compose run web betty-cropper create_token 13 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -euo pipefail 2 | # 3 | # Install dependencies. Recommened to run this from inside a virtual env. 4 | 5 | pip install -e . 6 | pip install "file://$(pwd)#egg=betty-cropper[dev]" 7 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run linter(s) over project 4 | 5 | docker-compose run web flake8 . 6 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | # Publish package to PyPi 3 | # 4 | # Requires you setup a PyPi account and setup your ~/.pypirc config 5 | 6 | # Make sure no local modifications 7 | if ! git diff-index --quiet HEAD --; then 8 | echo "Error! Local directory has changes. Please revert, stash or commit!" 9 | exit 1 10 | fi 11 | 12 | scripts/lint 13 | scripts/test 14 | 15 | # mparent(2017-07-13): Use legacy upload API, Betty Cropper is deprecated and don't want to figure out new API (though 16 | # might be simple). 17 | python setup.py sdist upload -r https://upload.pypi.org/legacy/ 18 | -------------------------------------------------------------------------------- /scripts/serve: -------------------------------------------------------------------------------- 1 | #!/bin/sh -euo pipefail 2 | # 3 | # Run local betty server 4 | 5 | docker-compose run --service-ports web 6 | #docker-compose run --service-ports web betty-cropper runserver 0.0.0.0:8080 7 | #docker-compose up --force-recreate 8 | -------------------------------------------------------------------------------- /scripts/shell: -------------------------------------------------------------------------------- 1 | #!/bin/sh -euo pipefail 2 | # 3 | # Open a Django shell 4 | 5 | docker-compose run web betty-cropper shell 6 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run automated tests 4 | 5 | if [ $# -eq 0 ]; then 6 | ARGS=tests/ 7 | else 8 | ARGS="$@" 9 | fi 10 | 11 | docker-compose run web py.test -s $ARGS 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files=test*.py 3 | addopts=--tb=short 4 | timeout=5 5 | django_find_project = false 6 | norecursedirs = lib bin include 7 | usefixtures = clear_cache 8 | 9 | [flake8] 10 | max-line-length = 100 11 | exclude = .ropeproject/*,*/migrations/* 12 | 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | from setuptools.command.test import test as TestCommand 6 | import os 7 | import re 8 | import sys 9 | 10 | 11 | name = 'betty-cropper' 12 | package = 'betty' 13 | description = "A django-powered image server" 14 | url = "https://github.com/theonion/betty-cropper" 15 | author = "Chris Sinchok" 16 | author_email = 'csinchok@theonion.com' 17 | license = 'MIT' 18 | 19 | setup_requires = [] 20 | 21 | 22 | def read_requirements(name): 23 | return open(os.path.join('requirements', name + '.txt')).readlines() 24 | 25 | 26 | imgmin_requires = read_requirements('imgmin') 27 | 28 | dev_requires = read_requirements('dev') + imgmin_requires 29 | 30 | install_requires = read_requirements('common') 31 | 32 | # Optional S3 storage, included for convenience 33 | s3_requires = read_requirements('s3') 34 | 35 | 36 | if 'test' in sys.argv: 37 | setup_requires.extend(dev_requires) 38 | 39 | 40 | def get_version(package): 41 | """ 42 | Return package version as listed in `__version__` in `init.py`. 43 | """ 44 | init_py = open(os.path.join(package, "__init__.py")).read() 45 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 46 | 47 | 48 | def get_packages(package): 49 | """ 50 | Return root package and all sub-packages. 51 | """ 52 | return [dirpath 53 | for dirpath, dirnames, filenames in os.walk(package) 54 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 55 | 56 | 57 | def get_package_data(package): 58 | """ 59 | Return all files under the root package, that are not in a 60 | package themselves. 61 | """ 62 | walk = [(dirpath.replace(package + os.sep, '', 1), filenames) 63 | for dirpath, dirnames, filenames in os.walk(package) 64 | if not os.path.exists(os.path.join(dirpath, '__init__.py'))] 65 | 66 | filepaths = [] 67 | for base, filenames in walk: 68 | filepaths.extend([os.path.join(base, filename) 69 | for filename in filenames]) 70 | return {package: filepaths} 71 | 72 | 73 | class PyTest(TestCommand): 74 | def finalize_options(self): 75 | TestCommand.finalize_options(self) 76 | self.test_args = ['tests'] 77 | self.test_suite = True 78 | 79 | def run_tests(self): 80 | # import here, cause outside the eggs aren't loaded 81 | import pytest 82 | errno = pytest.main(self.test_args) 83 | sys.exit(errno) 84 | 85 | 86 | setup( 87 | name=name, 88 | version=get_version(package), 89 | url=url, 90 | license=license, 91 | description=description, 92 | author=author, 93 | author_email=author_email, 94 | packages=get_packages(package), 95 | package_data={ 96 | "betty": ["cropper/templates/image.js", "cropper/font/OpenSans-Semibold.ttf"] 97 | }, 98 | install_requires=install_requires, 99 | tests_require=dev_requires, 100 | extras_require={ 101 | 'dev': dev_requires, 102 | 'imgmin': imgmin_requires, 103 | 's3': s3_requires, 104 | }, 105 | entry_points={ 106 | "console_scripts": [ 107 | "betty-cropper = betty.cropper.utils.runner:main", 108 | ], 109 | }, 110 | cmdclass={'test': PyTest} 111 | ) 112 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/__init__.py -------------------------------------------------------------------------------- /tests/betty.testconf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | 4 | MODULE_ROOT = os.path.dirname(os.path.realpath(__file__)) 5 | 6 | CACHES = { 7 | 'default': { 8 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 9 | 'LOCATION': 'betty-test-cache', 10 | } 11 | } 12 | BETTY_DEFAULT_IMAGE = 666 13 | BETTY_IMAGE_URL = "http://localhost:8081/images/" 14 | MEDIA_ROOT = tempfile.mkdtemp("bettycropper") 15 | BETTY_IMAGE_ROOT = MEDIA_ROOT 16 | TEMPLATE_DIRS = (os.path.join(MODULE_ROOT, "tests", "templates"),) 17 | BETTY_WIDTHS = [240, 640, 820, 960, 1200] 18 | -------------------------------------------------------------------------------- /tests/images/Header-Just_How.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Header-Just_How.jpg -------------------------------------------------------------------------------- /tests/images/Lenna.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Lenna.png -------------------------------------------------------------------------------- /tests/images/Lenna.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Lenna.psd -------------------------------------------------------------------------------- /tests/images/Lenna.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Lenna.tiff -------------------------------------------------------------------------------- /tests/images/Lenna_cmyk.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Lenna_cmyk.jpg -------------------------------------------------------------------------------- /tests/images/Sam_Hat1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Sam_Hat1.jpg -------------------------------------------------------------------------------- /tests/images/Sam_Hat1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Sam_Hat1.png -------------------------------------------------------------------------------- /tests/images/Sam_Hat1_gray.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Sam_Hat1_gray.jpg -------------------------------------------------------------------------------- /tests/images/Sam_Hat1_noext: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Sam_Hat1_noext -------------------------------------------------------------------------------- /tests/images/Simpsons-Week_a.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/Simpsons-Week_a.jpg -------------------------------------------------------------------------------- /tests/images/animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/animated.gif -------------------------------------------------------------------------------- /tests/images/huge.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/huge.jpg -------------------------------------------------------------------------------- /tests/images/tumblr.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theonion/betty-cropper/bb0e570c1eb0ddb2f39d109f996edd1d417d1fe4/tests/images/tumblr.jpg -------------------------------------------------------------------------------- /tests/test_animated.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from freezegun import freeze_time 4 | import pytest 5 | 6 | from django.core.files import File 7 | 8 | from betty.cropper.models import Image 9 | 10 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 11 | 12 | 13 | @pytest.fixture() 14 | def image(request): 15 | image = Image.objects.create( 16 | name="animated.gif", 17 | width=512, 18 | height=512, 19 | animated=True, 20 | ) 21 | 22 | lenna = File(open(os.path.join(TEST_DATA_PATH, "animated.gif"), "rb")) 23 | image.source.save("animated.gif", lenna) 24 | return image 25 | 26 | 27 | @freeze_time('2016-05-02 01:02:03') 28 | @pytest.mark.django_db 29 | @pytest.mark.usefixtures("clean_image_root") 30 | def test_get_jpg(client, image): 31 | res = client.get('/images/{}/animated/original.jpg'.format(image.id)) 32 | assert res.status_code == 200 33 | assert res['Content-Type'] == 'image/jpeg' 34 | assert res['Last-Modified'] == "Mon, 02 May 2016 01:02:03 GMT" 35 | saved_path = os.path.join(image.path(), 'animated/original.jpg') 36 | assert os.path.exists(saved_path) 37 | 38 | 39 | @pytest.mark.django_db 40 | @pytest.mark.usefixtures("clean_image_root") 41 | def test_get_animated_gif(client, image): 42 | res = client.get('/images/{}/animated/original.gif'.format(image.id)) 43 | assert res.status_code == 200 44 | assert res['Content-Type'] == 'image/gif' 45 | saved_path = os.path.join(image.path(), 'animated/original.gif') 46 | assert os.path.exists(saved_path) 47 | 48 | 49 | @pytest.mark.django_db 50 | @pytest.mark.usefixtures("clean_image_root") 51 | def test_get_unsupported_extension(client, image): 52 | res = client.get('/images/{}/animated/original.png'.format(image.id)) 53 | assert res.status_code == 404 54 | 55 | 56 | @pytest.mark.django_db 57 | @pytest.mark.usefixtures("clean_image_root") 58 | def test_get_not_animated(client, image): 59 | image.animated = False 60 | image.save() 61 | res = client.get('/images/{}/animated/original.gif'.format(image.id)) 62 | assert res.status_code == 404 63 | 64 | 65 | @pytest.mark.django_db 66 | @pytest.mark.usefixtures("clean_image_root") 67 | def test_get_invalid_image(client): 68 | res = client.get('/images/1/animated/original.gif') 69 | assert res.status_code == 404 70 | 71 | 72 | def test_get_animated_url(): 73 | image = Image(id=1) 74 | assert '/images/1/animated/original.gif' == image.get_animated_url(extension='gif') 75 | assert '/images/1/animated/original.jpg' == image.get_animated_url(extension='jpg') 76 | 77 | 78 | @pytest.mark.django_db 79 | @pytest.mark.usefixtures("clean_image_root") 80 | def test_if_modified_since(settings, client, image): 81 | settings.BETTY_CACHE_CROP_SEC = 600 82 | res = client.get('/images/{}/animated/original.gif'.format(image.id), 83 | HTTP_IF_MODIFIED_SINCE="Sat, 01 May 2100 00:00:00 GMT") 84 | assert res.status_code == 304 85 | assert res['Cache-Control'] == 'max-age=600' 86 | assert not res.content # Empty content 87 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from mock import call, patch 5 | import pytest 6 | 7 | from betty.cropper.models import Image 8 | 9 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 10 | 11 | 12 | def test_no_api_key(client): 13 | res = client.post('/images/api/new') 14 | assert res.status_code == 403 15 | 16 | res = client.get('/images/api/1') 17 | assert res.status_code == 403 18 | 19 | res = client.post('/images/api/1/1x1') 20 | assert res.status_code == 403 21 | 22 | res = client.post('/images/api/1', REQUEST_METHOD="PATCH") 23 | assert res.status_code == 403 24 | 25 | res = client.get('/images/api/search') 26 | assert res.status_code == 403 27 | 28 | 29 | def create_test_image(admin_client): 30 | lenna_path = os.path.join(TEST_DATA_PATH, 'Lenna.png') 31 | with open(lenna_path, "rb") as lenna: 32 | data = {"image": lenna, "name": "LENNA DOT PNG", "credit": "Playboy"} 33 | res = admin_client.post('/images/api/new', data) 34 | assert res.status_code == 200 35 | 36 | return json.loads(res.content.decode("utf-8")) 37 | 38 | 39 | @pytest.mark.django_db 40 | @pytest.mark.usefixtures("clean_image_root") 41 | def test_image_upload(admin_client): 42 | 43 | response_json = create_test_image(admin_client) 44 | 45 | del response_json["selections"] # We don't care about selections in this test 46 | assert response_json == { 47 | "id": 1, 48 | "name": "LENNA DOT PNG", 49 | "credit": "Playboy", 50 | "height": 512, 51 | "width": 512 52 | } 53 | 54 | image = Image.objects.get(id=response_json['id']) 55 | assert os.path.exists(image.path()) 56 | assert os.path.exists(image.source.path) 57 | assert os.path.exists(image.optimized.path) 58 | assert os.path.basename(image.source.path) == "Lenna.png" 59 | assert image.name == "LENNA DOT PNG" 60 | assert image.credit == "Playboy" 61 | 62 | 63 | @pytest.mark.django_db 64 | def test_update_selection(admin_client): 65 | 66 | image = Image.objects.create(name="Testing", width=512, height=512) 67 | 68 | new_selection = { 69 | "x0": 1, 70 | "y0": 1, 71 | "x1": 510, 72 | "y1": 510 73 | } 74 | 75 | res = admin_client.post( 76 | "/images/api/{0}/1x1".format(image.id), 77 | data=json.dumps(new_selection), 78 | content_type="application/json", 79 | ) 80 | assert res.status_code == 200 81 | 82 | image = Image.objects.get(id=image.id) 83 | assert new_selection == image.selections['1x1'] 84 | 85 | res = admin_client.post( 86 | "/images/api/{0}/original".format(image.id), 87 | data=json.dumps(new_selection), 88 | content_type="application/json", 89 | ) 90 | assert res.status_code == 400 91 | 92 | bad_selection = { 93 | 'x0': 1, 94 | 'x1': 510 95 | } 96 | res = admin_client.post( 97 | "/images/api/{0}/1x1".format(image.id), 98 | data=json.dumps(bad_selection), 99 | content_type="application/json", 100 | ) 101 | assert res.status_code == 400 102 | 103 | res = admin_client.post( 104 | "/images/api/1000001/1x1", 105 | data=json.dumps(bad_selection), 106 | content_type="application/json", 107 | ) 108 | assert res.status_code == 404 109 | 110 | 111 | @pytest.mark.django_db 112 | def test_image_selection_source(admin_client): 113 | image = Image.objects.create(name="Testing", width=512, height=512) 114 | image.selections = {"1x1": {"x0": 1, "y0": 1, "x1": 510, "y1": 510}} 115 | image.save() 116 | 117 | res = admin_client.get("/images/api/{0}".format(image.id)) 118 | assert res.status_code == 200 119 | data = json.loads(res.content.decode("utf-8")) 120 | assert data["selections"]["1x1"]["source"] == "user" 121 | assert data["selections"]["16x9"]["source"] == "auto" 122 | 123 | 124 | @pytest.mark.django_db 125 | @pytest.mark.usefixtures("clean_image_root") 126 | def test_crop_clearing_enable_save_crops(admin_client, settings): 127 | settings.BETTY_SAVE_CROPS_TO_DISK = True 128 | 129 | response_json = create_test_image(admin_client) 130 | image_id = response_json['id'] 131 | 132 | # Now let's generate a couple crops 133 | admin_client.get("/images/{}/1x1/240.jpg".format(image_id)) 134 | admin_client.get("/images/{}/16x9/640.png".format(image_id)) 135 | 136 | image = Image.objects.get(id=image_id) 137 | 138 | assert os.path.exists(os.path.join(image.path(), "1x1", "240.jpg")) 139 | assert os.path.exists(os.path.join(image.path(), "16x9", "640.png")) 140 | 141 | # Now we update the selection 142 | new_selection = { 143 | "x0": 1, 144 | "y0": 1, 145 | "x1": 510, 146 | "y1": 510 147 | } 148 | 149 | res = admin_client.post( 150 | "/images/api/{0}/1x1".format(image_id), 151 | data=json.dumps(new_selection), 152 | content_type="application/json", 153 | ) 154 | assert res.status_code == 200 155 | 156 | # Let's make sure that the crops got removed 157 | assert not os.path.exists(os.path.join(image.path(), "1x1", "240.jpg")) 158 | assert os.path.exists(os.path.join(image.path(), "16x9", "640.png")) 159 | 160 | 161 | @pytest.mark.django_db 162 | @pytest.mark.usefixtures("clean_image_root") 163 | def test_crop_clearing_disable_save_crops(admin_client, settings): 164 | 165 | settings.BETTY_SAVE_CROPS_TO_DISK = False 166 | 167 | response_json = create_test_image(admin_client) 168 | image_id = response_json['id'] 169 | 170 | # Generate a crop 171 | admin_client.get("/images/{}/1x1/240.jpg".format(image_id)) 172 | 173 | image = Image.objects.get(id=image_id) 174 | 175 | # Verify no crop file saved to disk 176 | assert not os.path.exists(os.path.join(image.path(), "1x1", "240.jpg")) 177 | 178 | 179 | @pytest.mark.django_db 180 | @pytest.mark.usefixtures("clean_image_root") 181 | def test_image_delete(admin_client, settings): 182 | settings.BETTY_SAVE_CROPS_TO_DISK = True 183 | 184 | resp_json = create_test_image(admin_client) 185 | image_id = resp_json['id'] 186 | 187 | with patch.object(Image, 'clear_crops') as mock_clear_crops: 188 | with patch('os.remove') as mock_remove: 189 | res = admin_client.post( 190 | "/images/api/{0}".format(image_id), 191 | content_type="application/json", 192 | REQUEST_METHOD="DELETE", 193 | ) 194 | assert res.status_code == 200 195 | assert not Image.objects.filter(id=image_id) 196 | assert mock_clear_crops.called 197 | image_dir = os.path.join(settings.BETTY_IMAGE_ROOT, str(image_id)) 198 | mock_remove.assert_has_calls([call(os.path.join(image_dir, 'Lenna.png')), 199 | call(os.path.join(image_dir, 'optimized.png'))]) 200 | 201 | 202 | @pytest.mark.django_db 203 | def test_image_delete_invalid_id(admin_client): 204 | res = admin_client.post( 205 | "/images/api/{0}".format(101), 206 | content_type="application/json", 207 | REQUEST_METHOD="DELETE", 208 | ) 209 | assert res.status_code == 404 210 | 211 | 212 | @pytest.mark.django_db 213 | def test_image_detail(admin_client): 214 | image = Image.objects.create(name="Testing", width=512, height=512) 215 | 216 | res = admin_client.get("/images/api/{0}".format(image.id)) 217 | assert res.status_code == 200 218 | 219 | res = admin_client.post( 220 | "/images/api/{0}".format(image.id), 221 | data=json.dumps({"name": "Updated"}), 222 | content_type="application/json", 223 | REQUEST_METHOD="PATCH", 224 | ) 225 | assert res.status_code == 200 226 | 227 | image = Image.objects.get(id=image.id) 228 | assert image.name == "Updated" 229 | 230 | 231 | def test_image_search(admin_client): 232 | image = Image.objects.create(name="BLERGH", width=512, height=512) 233 | 234 | res = admin_client.get('/images/api/search?q=blergh') 235 | assert res.status_code == 200 236 | results = json.loads(res.content.decode("utf-8")) 237 | assert len(results) == 1 238 | assert results["results"][0]["id"] == image.id 239 | 240 | 241 | @pytest.mark.usefixtures("clean_image_root") 242 | def test_bad_image_data(admin_client): 243 | 244 | response_json = create_test_image(admin_client) 245 | 246 | assert response_json.get("name") == "LENNA DOT PNG" 247 | assert response_json.get("width") == 512 248 | assert response_json.get("height") == 512 249 | 250 | # Now that the image is uploaded, let's fuck up the data. 251 | image = Image.objects.get(id=response_json['id']) 252 | image.width = 1024 253 | image.height = 1024 254 | image.save() 255 | 256 | id_string = "" 257 | for index, char in enumerate(str(image.id)): 258 | if index % 4 == 0: 259 | id_string += "/" 260 | id_string += char 261 | res = admin_client.get('/images/{0}/1x1/400.jpg'.format(id_string)) 262 | assert res.status_code == 200 263 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | 4 | from django.test import Client 5 | 6 | from betty.authtoken.models import ApiToken 7 | 8 | 9 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_user_creation(client): 14 | token = ApiToken.objects.create_standard_user() 15 | assert token.public_token is not None 16 | assert token.private_token is not None 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_search_api(client): 21 | token = ApiToken.objects.create_standard_user() 22 | client = Client() 23 | response = client.get( 24 | "/images/api/search?q=testing", 25 | HTTP_X_BETTY_API_KEY=token.public_token) 26 | assert response.status_code == 200 27 | -------------------------------------------------------------------------------- /tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from httmock import all_requests, HTTMock, response, urlmatch 4 | from mock import patch 5 | import requests 6 | 7 | from betty.contrib.cacheflush import cachemaster 8 | 9 | 10 | def test_cachemaster_flush(settings): 11 | settings.BETTY_IMAGE_URL = 'http://onion.local' 12 | settings.CACHEMASTER_URLS = ['http://cachemaster.local/flush'] 13 | 14 | @all_requests 15 | def success(url, request): 16 | assert request.url == 'http://cachemaster.local/flush' 17 | assert request.method == 'POST' 18 | assert json.loads(request.body.decode('utf-8')) == { 19 | 'urls': ['http://onion.local/path/one', 20 | 'http://onion.local/two/'] 21 | } 22 | return response(200, 'YAY TEST WORKED') 23 | 24 | with HTTMock(success): 25 | resp = cachemaster.flush(['/path/one', '/two/']) 26 | 27 | assert resp.text == 'YAY TEST WORKED' # Ensures we actually hit the mock 28 | 29 | 30 | def test_cachemaster_first_flush_fails(settings): 31 | settings.BETTY_IMAGE_URL = 'http://onion.local' 32 | settings.CACHEMASTER_URLS = ['http://cachemaster1.local/flush', 33 | 'http://cachemaster2.local/flush', 34 | 'http://cachemaster3.local/flush'] 35 | 36 | @urlmatch(netloc='cachemaster1.local', path='/flush') 37 | def error_500(url, request): 38 | return response(500, request=request) # Simulate failed request 39 | 40 | @urlmatch(netloc='cachemaster2.local', path='/flush') 41 | def error_connection(url, request): 42 | raise requests.ConnectionError 43 | 44 | @urlmatch(netloc='cachemaster3.local', path='/flush', method='POST') 45 | def success(url, request): 46 | return response(200, 'YAY TEST WORKED') 47 | 48 | with HTTMock(error_500, error_connection, success): 49 | with patch('betty.contrib.cacheflush.cachemaster.logger') as mock_log: 50 | resp = cachemaster.flush(['/path/one']) 51 | # Two errors 52 | assert 2 == (mock_log.error.call_count + mock_log.exception.call_count) 53 | 54 | assert resp.text == 'YAY TEST WORKED' # Ensures we actually hit the mock 55 | 56 | 57 | def test_cachemaster_all_fail(settings): 58 | settings.CACHEMASTER_URLS = ['http://cachemaster1.local/flush', 59 | 'http://cachemaster2.local/flush'] 60 | 61 | @all_requests 62 | def error_500(url, request): 63 | return response(500) 64 | 65 | with HTTMock(error_500): 66 | with patch('betty.contrib.cacheflush.cachemaster.logger') as mock_log: 67 | resp = cachemaster.flush(['/path/one']) 68 | assert 2 == mock_log.error.call_count 69 | 70 | assert resp is None 71 | -------------------------------------------------------------------------------- /tests/test_crop_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from django.http import HttpRequest 4 | from django.utils import timezone 5 | 6 | from betty.cropper.utils import seconds_since_epoch 7 | from betty.cropper.utils.http import check_not_modified 8 | 9 | 10 | def test_seconds_since_epoch(): 11 | return 0 == seconds_since_epoch(datetime(1970, 1, 1)) 12 | return 1462218127 == seconds_since_epoch(datetime(2016, 5, 2, 19, 42, 7)) 13 | 14 | 15 | def test_check_not_modified_missing_header(): 16 | request = HttpRequest() 17 | # Never succeeds if missing "If-Modified-Since" header 18 | assert not check_not_modified(request, None) 19 | assert not check_not_modified(request, timezone.now()) 20 | assert not check_not_modified(request, datetime(1994, 11, 6, tzinfo=timezone.utc)) 21 | 22 | 23 | def test_check_not_modified_has_if_modified_since_header(): 24 | request = HttpRequest() 25 | request.META['HTTP_IF_MODIFIED_SINCE'] = "Sun, 06 Nov 1994 08:49:37 GMT" 26 | # Fails if "last_modified" invalid 27 | assert not check_not_modified(request, None) 28 | # Before 29 | when = datetime(1994, 11, 6, 8, 49, 37, tzinfo=timezone.utc) 30 | assert check_not_modified(request, when - timedelta(seconds=1)) 31 | # Identical 32 | assert check_not_modified(request, when) 33 | # After 34 | assert not check_not_modified(request, when + timedelta(seconds=1)) 35 | -------------------------------------------------------------------------------- /tests/test_cropping.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from freezegun import freeze_time 5 | from PIL import Image as PILImage 6 | import pytest 7 | 8 | from django.core.files import File 9 | 10 | from betty.conf.app import settings 11 | from betty.cropper.models import Image, Ratio 12 | 13 | from mock import patch 14 | 15 | 16 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 17 | 18 | 19 | @freeze_time('2016-05-02 01:02:03') 20 | @pytest.mark.django_db 21 | @pytest.mark.usefixtures("clean_image_root") 22 | def test_basic_cropping(settings, client, image): 23 | settings.BETTY_CACHE_CROP_SEC = 600 24 | res = client.get('/images/{}/1x1/200.jpg'.format(image.id)) 25 | assert res.status_code == 200 26 | assert res['Cache-Control'] == 'max-age=600' 27 | assert res['Last-Modified'] == "Mon, 02 May 2016 01:02:03 GMT" 28 | assert res['Content-Type'] == "image/jpeg" 29 | 30 | image_buffer = io.BytesIO(res.content) 31 | img = PILImage.open(image_buffer) 32 | assert img.size == (200, 200) 33 | 34 | 35 | @pytest.mark.django_db 36 | def test_image_selections(): 37 | 38 | image = Image.objects.create( 39 | name="Lenna.gif", 40 | width=512, 41 | height=512 42 | ) 43 | 44 | # Test to make sure the default selections work 45 | assert image.get_selection(Ratio('1x1')) == {'x0': 0, 'y0': 0, 'x1': 512, 'y1': 512} 46 | 47 | # Now let's add some bad data 48 | image.selections = { 49 | '1x1': { 50 | 'x0': 0, 51 | 'y0': 0, 52 | 'x1': 513, 53 | 'y1': 512 54 | } 55 | } 56 | image.save() 57 | 58 | # Now, that was a bad selection, so we should be getting an auto generated one. 59 | assert image.get_selection(Ratio('1x1')) == {'x0': 0, 'y0': 0, 'x1': 512, 'y1': 512} 60 | 61 | # Try with a negative value 62 | image.selections = { 63 | '1x1': { 64 | 'x0': -1, 65 | 'y0': 0, 66 | 'x1': 512, 67 | 'y1': 512 68 | } 69 | } 70 | image.save() 71 | assert image.get_selection(Ratio('1x1')) == {'x0': 0, 'y0': 0, 'x1': 512, 'y1': 512} 72 | 73 | # Try with another negative value 74 | image.selections = { 75 | '1x1': { 76 | 'x0': 0, 77 | 'y0': 0, 78 | 'x1': -1, 79 | 'y1': 512 80 | } 81 | } 82 | image.save() 83 | assert image.get_selection(Ratio('1x1')) == {'x0': 0, 'y0': 0, 'x1': 512, 'y1': 512} 84 | 85 | # Try with bad x values 86 | image.selections = { 87 | '1x1': { 88 | 'x0': 10, 89 | 'y0': 0, 90 | 'x1': 9, 91 | 'y1': 512 92 | } 93 | } 94 | image.save() 95 | assert image.get_selection(Ratio('1x1')) == {'x0': 0, 'y0': 0, 'x1': 512, 'y1': 512} 96 | 97 | 98 | def test_bad_image_id(client): 99 | res = client.get('/images/abc/13x4/256.jpg') 100 | assert res.status_code == 404 101 | 102 | 103 | def test_bad_ratio(client): 104 | res = client.get('/images/666/13x4/256.jpg') 105 | assert res.status_code == 404 106 | 107 | 108 | def test_malformed_ratio(client): 109 | res = client.get('/images/666/farts/256.jpg') 110 | assert res.status_code == 404 111 | 112 | 113 | def test_bad_extension(client): 114 | res = client.get('/images/666/1x1/500.gif') 115 | assert res.status_code == 404 116 | 117 | res = client.get('/images/666/1x1/500.pngbutts') 118 | assert res.status_code == 404 119 | 120 | 121 | def test_too_large(client): 122 | res = client.get("/images/666/1x1/{}.jpg".format(settings.BETTY_MAX_WIDTH + 1)) 123 | assert res.status_code == 500 124 | 125 | 126 | def test_image_redirect(client): 127 | res = client.get('/images/666666/1x1/100.jpg') 128 | assert res.status_code == 302 129 | assert res['Location'].endswith("/images/6666/66/1x1/100.jpg") 130 | 131 | 132 | @pytest.fixture() 133 | def image(request): 134 | image = Image.objects.create( 135 | name="Lenna.png", 136 | width=512, 137 | height=512 138 | ) 139 | 140 | lenna = File(open(os.path.join(TEST_DATA_PATH, "Lenna.png"), "rb")) 141 | image.source.save("Lenna.png", lenna) 142 | return image 143 | 144 | 145 | @pytest.mark.django_db 146 | @pytest.mark.usefixtures("clean_image_root") 147 | def test_crop_caching(settings, client, image): 148 | 149 | settings.BETTY_CACHE_CROP_SEC = 1234 150 | settings.BETTY_CACHE_CROP_NON_BREAKPOINT_SEC = 456 151 | settings.BETTY_WIDTHS = [100] 152 | settings.BETTY_CLIENT_ONLY_WIDTHS = [200] 153 | 154 | # Breakpoint 155 | for width in [100, 200]: 156 | res = client.get('/images/{image_id}/1x1/{width}.jpg'.format(image_id=image.id, 157 | width=width)) 158 | assert res.status_code == 200 159 | assert res['Cache-Control'] == 'max-age=1234' 160 | 161 | # Non-Breakpoint 162 | res = client.get('/images/{}/1x1/300.jpg'.format(image.id)) 163 | assert res.status_code == 200 164 | assert res['Cache-Control'] == 'max-age=456' 165 | 166 | 167 | @pytest.mark.django_db 168 | @pytest.mark.usefixtures("clean_image_root") 169 | def test_crop_caching_default_non_breakpoint(settings, client, image): 170 | 171 | settings.BETTY_CACHE_CROP_SEC = 1234 172 | 173 | # BETTY_CACHE_CROP_NON_BREAKPOINT_SEC not set, uses BETTY_CACHE_CROP_SEC 174 | res = client.get('/images/{}/1x1/100.jpg'.format(image.id)) 175 | assert res.status_code == 200 176 | assert res['Cache-Control'] == 'max-age=1234' 177 | 178 | 179 | @pytest.mark.django_db 180 | def test_placeholder(settings, client): 181 | settings.BETTY_PLACEHOLDER = True 182 | 183 | res = client.get('/images/666/original/256.jpg') 184 | assert res['Content-Type'] == 'image/jpeg' 185 | assert res.status_code == 200 186 | 187 | res = client.get('/images/666/1x1/256.jpg') 188 | assert res.status_code == 200 189 | assert res['Content-Type'] == 'image/jpeg' 190 | 191 | res = client.get('/images/666/1x1/256.png') 192 | assert res['Content-Type'] == 'image/png' 193 | assert res.status_code == 200 194 | 195 | settings.BETTY_PLACEHOLDER = False 196 | res = client.get('/images/666/1x1/256.jpg') 197 | assert res.status_code == 404 198 | 199 | 200 | @pytest.mark.django_db 201 | def test_missing_file(client): 202 | with patch('betty.cropper.views.logger') as mock_logger: 203 | image = Image.objects.create(name="Lenna.gif", width=512, height=512) 204 | 205 | res = client.get('/images/{0}/1x1/256.jpg'.format(image.id)) 206 | assert res.status_code == 500 207 | assert mock_logger.exception.call_args[0][0].startswith('Cropping error') 208 | 209 | 210 | @pytest.mark.django_db 211 | @pytest.mark.usefixtures("clean_image_root") 212 | def test_image_save(client, image): 213 | 214 | # Now let's test that a JPEG crop will return properly. 215 | res = client.get('/images/{}/1x1/240.jpg'.format(image.id)) 216 | assert res['Content-Type'] == 'image/jpeg' 217 | assert res.status_code == 200 218 | assert os.path.exists(os.path.join(image.path(), '1x1', '240.jpg')) 219 | 220 | # Now let's test that a PNG crop will return properly. 221 | res = client.get('/images/{}/1x1/240.png'.format(image.id)) 222 | assert res['Content-Type'] == 'image/png' 223 | assert res.status_code == 200 224 | assert os.path.exists(os.path.join(image.path(), '1x1', '240.png')) 225 | 226 | # Let's test an "original" crop 227 | res = client.get('/images/{}/original/240.jpg'.format(image.id)) 228 | assert res['Content-Type'] == 'image/jpeg' 229 | assert res.status_code == 200 230 | assert os.path.exists(os.path.join(image.path(), 'original', '240.jpg')) 231 | 232 | # Finally, let's test a width that doesn't exist 233 | res = client.get('/images/{}/original/666.jpg'.format(image.id)) 234 | res['Content-Type'] == 'image/jpeg' 235 | assert res.status_code == 200 236 | assert not os.path.exists(os.path.join(image.path(), 'original', '666.jpg')) 237 | 238 | 239 | @pytest.mark.django_db 240 | @pytest.mark.usefixtures("clean_image_root") 241 | def test_disable_crop_save(client, settings, image): 242 | settings.BETTY_SAVE_CROPS_TO_DISK = False 243 | 244 | # Now let's test that a JPEG crop will return properly. 245 | res = client.get('/images/{}/1x1/240.jpg'.format(image.id)) 246 | assert res['Content-Type'] == 'image/jpeg' 247 | assert res.status_code == 200 248 | # Verify crop not saved to disk 249 | assert not os.path.exists(os.path.join(image.path(), '1x1', '240.jpg')) 250 | 251 | 252 | @pytest.mark.django_db 253 | @pytest.mark.usefixtures("clean_image_root") 254 | def test_non_rgb(client): 255 | 256 | image = Image.objects.create( 257 | name="animated.gif", 258 | width=512, 259 | height=512 260 | ) 261 | 262 | lenna = File(open(os.path.join(TEST_DATA_PATH, "animated.gif"), "rb")) 263 | image.source.save("animated.gif", lenna) 264 | 265 | res = client.get('/images/{}/1x1/240.jpg'.format(image.id)) 266 | assert res.status_code == 200 267 | assert res['Content-Type'] == 'image/jpeg' 268 | cropped_path = os.path.join(image.path(), '1x1/240.jpg') 269 | assert os.path.exists(cropped_path) 270 | 271 | res = client.get('/images/{}/original/1200.jpg'.format(image.id)) 272 | assert res.status_code == 200 273 | assert res['Content-Type'] == 'image/jpeg' 274 | assert os.path.exists(os.path.join(image.path(), 'original/1200.jpg')) 275 | 276 | 277 | @pytest.mark.django_db 278 | @pytest.mark.usefixtures("clean_image_root") 279 | def test_jpg_cmyk_to_png(client): 280 | 281 | image = Image.objects.create( 282 | name="Lenna_cmyk.jpg", 283 | width=512, 284 | height=512 285 | ) 286 | 287 | lenna = File(open(os.path.join(TEST_DATA_PATH, "Lenna_cmyk.jpg"), "rb")) 288 | image.source.save("Lenna_cmyk.jpg", lenna) 289 | 290 | res = client.get('/images/{}/original/1200.png'.format(image.id)) 291 | assert res.status_code == 200 292 | assert res['Content-Type'] == 'image/png' 293 | assert os.path.exists(os.path.join(image.path(), 'original/1200.png')) 294 | 295 | 296 | def test_image_js(settings, client): 297 | settings.BETTY_WIDTHS = [100, 200] 298 | settings.BETTY_CLIENT_ONLY_WIDTHS = [2, 1] 299 | settings.BETTY_IMAGE_URL = 'http://test.example.org/images' 300 | res = client.get("/images/image.js") 301 | assert res.status_code == 200 302 | assert res['Content-Type'] == 'application/javascript' 303 | # Sorted + appends '0' if missing 304 | assert res.context['BETTY_WIDTHS'] == [0, 1, 2, 100, 200] 305 | assert res.context['BETTY_IMAGE_URL'] == '//test.example.org/images' 306 | 307 | 308 | def test_image_js_use_request_host(settings, client): 309 | settings.BETTY_IMAGE_URL = 'http://test.example.org/images' 310 | settings.BETTY_IMAGE_URL_USE_REQUEST_HOST = True 311 | res = client.get("/images/image.js", SERVER_NAME='alt.example.org') 312 | assert res.status_code == 200 313 | assert res.context['BETTY_IMAGE_URL'] == '//alt.example.org/images' 314 | 315 | 316 | @pytest.mark.django_db 317 | @pytest.mark.usefixtures("clean_image_root") 318 | def test_if_modified_since(settings, client, image): 319 | settings.BETTY_CACHE_CROP_SEC = 600 320 | res = client.get('/images/{}/1x1/300.jpg'.format(image.id), 321 | HTTP_IF_MODIFIED_SINCE="Sat, 01 May 2100 00:00:00 GMT") 322 | assert res.status_code == 304 323 | assert res['Cache-Control'] == 'max-age=600' 324 | assert not res.content # Empty content 325 | -------------------------------------------------------------------------------- /tests/test_flush.py: -------------------------------------------------------------------------------- 1 | from betty.cropper.flush import get_cache_flusher 2 | 3 | 4 | def mock_flusher(paths): 5 | pass 6 | 7 | 8 | def test_get_cache_flusher_string(settings): 9 | settings.BETTY_CACHE_FLUSHER = 'tests.test_flush.mock_flusher' 10 | assert mock_flusher == get_cache_flusher() 11 | 12 | 13 | def test_get_cache_flusher_callable(settings): 14 | settings.BETTY_CACHE_FLUSHER = mock_flusher 15 | assert mock_flusher == get_cache_flusher() 16 | -------------------------------------------------------------------------------- /tests/test_image_files.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | 4 | from mock import patch 5 | import pytest 6 | 7 | from PIL import Image as PILImage 8 | from PIL import JpegImagePlugin 9 | 10 | from betty.cropper.models import Image 11 | from betty.conf.app import settings as bettysettings 12 | 13 | 14 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 15 | 16 | 17 | @pytest.mark.django_db 18 | @pytest.mark.usefixtures("clean_image_root") 19 | def test_png(): 20 | 21 | path = os.path.join(TEST_DATA_PATH, "Lenna.png") 22 | image = Image.objects.create_from_path(path) 23 | 24 | # Re-load the image, now that the task is done 25 | image = Image.objects.get(id=image.id) 26 | 27 | assert image.source.path.endswith("Lenna.png") 28 | assert image.width == 512 29 | assert image.height == 512 30 | assert image.jpeg_quality is None 31 | assert os.path.exists(image.optimized.path) 32 | assert os.path.exists(image.source.path) 33 | assert not image.animated 34 | 35 | # Since this image is 512x512, it shouldn't end up getting changed by the optimization process 36 | assert os.stat(image.optimized.path).st_size == os.stat(image.source.path).st_size 37 | 38 | 39 | @pytest.mark.django_db 40 | @pytest.mark.usefixtures("clean_image_root") 41 | def test_jpeg(): 42 | 43 | path = os.path.join(TEST_DATA_PATH, "Sam_Hat1.jpg") 44 | image = Image.objects.create_from_path(path) 45 | 46 | # Re-load the image, now that the task is done 47 | image = Image.objects.get(id=image.id) 48 | 49 | assert image.source.path.endswith("Sam_Hat1.jpg") 50 | assert image.width == 3264 51 | assert image.height == 2448 52 | assert image.jpeg_quality is None 53 | assert os.path.exists(image.optimized.path) 54 | assert os.path.exists(image.source.path) 55 | assert not image.animated 56 | 57 | source = PILImage.open(image.source.path) 58 | optimized = PILImage.open(image.optimized.path) 59 | 60 | assert source.quantization == optimized.quantization 61 | assert JpegImagePlugin.get_sampling(source) == JpegImagePlugin.get_sampling(optimized) 62 | 63 | 64 | @pytest.mark.django_db 65 | @pytest.mark.usefixtures("clean_image_root") 66 | def test_jpeg_grayscale(): 67 | 68 | path = os.path.join(TEST_DATA_PATH, "Sam_Hat1_gray.jpg") 69 | image = Image.objects.create_from_path(path) 70 | 71 | # Re-load the image, now that the task is done 72 | image = Image.objects.get(id=image.id) 73 | 74 | assert image.source.path.endswith("Sam_Hat1_gray.jpg") 75 | assert image.width == 3264 76 | assert image.height == 2448 77 | assert image.jpeg_quality is None 78 | assert os.path.exists(image.optimized.path) 79 | assert os.path.exists(image.source.path) 80 | assert not image.animated 81 | 82 | source = PILImage.open(image.source.path) 83 | optimized = PILImage.open(image.optimized.path) 84 | 85 | assert source.mode == 'L' 86 | assert optimized.mode == 'L' 87 | 88 | assert source.size == (3264, 2448) 89 | assert optimized.size == (3200, 2400) 90 | 91 | 92 | @pytest.mark.django_db 93 | @pytest.mark.usefixtures("clean_image_root") 94 | def test_jpeg_save_quant_error(): 95 | path = os.path.join(TEST_DATA_PATH, "Sam_Hat1.jpg") 96 | 97 | def image_save_quant_error(*args, **kw): 98 | if 'qtables' in kw: 99 | raise ValueError("Invalid quantization table") 100 | 101 | with patch.object(PILImage.Image, 'save', side_effect=image_save_quant_error) as mock_save: 102 | Image.objects.create_from_path(path) 103 | 104 | # First save fails, second save succeeds 105 | assert mock_save.call_count == 2 106 | 107 | second_call = mock_save.call_args_list[1] 108 | assert isinstance(second_call[0][0], io.BytesIO) 109 | assert second_call[1] == {'format': 'JPEG', 'icc_profile': None} 110 | 111 | 112 | @pytest.mark.django_db 113 | @pytest.mark.usefixtures("clean_image_root") 114 | def test_jpeg_noext(): 115 | 116 | path = os.path.join(TEST_DATA_PATH, "Sam_Hat1_noext") 117 | image = Image.objects.create_from_path(path) 118 | 119 | # Re-load the image, now that the task is done 120 | image = Image.objects.get(id=image.id) 121 | 122 | assert image.source.path.endswith("Sam_Hat1_noext") 123 | assert image.width == 3264 124 | assert image.height == 2448 125 | assert image.jpeg_quality is None 126 | assert os.path.exists(image.optimized.path) 127 | assert os.path.exists(image.source.path) 128 | assert not image.animated 129 | 130 | source = PILImage.open(image.source.path) 131 | optimized = PILImage.open(image.optimized.path) 132 | 133 | assert source.quantization == optimized.quantization 134 | 135 | assert JpegImagePlugin.get_sampling(source) == JpegImagePlugin.get_sampling(optimized) 136 | 137 | 138 | @pytest.mark.django_db 139 | @pytest.mark.usefixtures("clean_image_root") 140 | def test_huge_jpeg(): 141 | 142 | path = os.path.join(TEST_DATA_PATH, "huge.jpg") 143 | image = Image.objects.create_from_path(path) 144 | 145 | # Re-load the image, now that the task is done 146 | image = Image.objects.get(id=image.id) 147 | 148 | assert image.source.path.endswith("huge.jpg") 149 | assert image.width == 8720 150 | assert image.height == 8494 151 | assert image.jpeg_quality is None 152 | assert os.path.exists(image.optimized.path) 153 | assert os.path.exists(image.source.path) 154 | assert not image.animated 155 | 156 | assert image.to_native()["width"] == 3200 157 | 158 | optimized = PILImage.open(image.optimized.path) 159 | assert optimized.size[0] == bettysettings.BETTY_MAX_WIDTH 160 | assert os.stat(image.optimized.path).st_size < os.stat(image.source.path).st_size 161 | 162 | 163 | @pytest.mark.django_db 164 | @pytest.mark.usefixtures("clean_image_root") 165 | def test_l_mode(): 166 | 167 | path = os.path.join(TEST_DATA_PATH, "Header-Just_How.jpg") 168 | image = Image.objects.create_from_path(path) 169 | 170 | # Re-load the image, now that the task is done 171 | image = Image.objects.get(id=image.id) 172 | 173 | assert image.source.path.endswith("Header-Just_How.jpg") 174 | assert image.width == 1280 175 | assert image.height == 720 176 | assert image.jpeg_quality is None 177 | assert os.path.exists(image.optimized.path) 178 | assert os.path.exists(image.source.path) 179 | assert not image.animated 180 | 181 | 182 | @pytest.mark.django_db 183 | @pytest.mark.usefixtures("clean_image_root") 184 | def test_fucked_up_quant_tables(): 185 | 186 | path = os.path.join(TEST_DATA_PATH, "tumblr.jpg") 187 | image = Image.objects.create_from_path(path) 188 | 189 | # Re-load the image, now that the task is done 190 | image = Image.objects.get(id=image.id) 191 | 192 | assert image.source.path.endswith("tumblr.jpg") 193 | assert image.width == 1280 194 | assert image.height == 704 195 | 196 | 197 | @pytest.mark.django_db 198 | @pytest.mark.usefixtures("clean_image_root") 199 | def test_gif_upload(): 200 | 201 | path = os.path.join(TEST_DATA_PATH, "animated.gif") 202 | image = Image.objects.create_from_path(path) 203 | 204 | # Re-load the image, now that the task is done 205 | image = Image.objects.get(id=image.id) 206 | 207 | assert image.width == 256 208 | assert image.height == 256 209 | assert os.path.exists(image.path()) 210 | assert os.path.exists(image.source.path) 211 | assert os.path.basename(image.source.path) == "animated.gif" 212 | assert image.animated 213 | 214 | # mparent(2016-03-18): These are now created on demand 215 | assert not os.path.exists(os.path.join(image.path(), "animated/original.gif")) 216 | assert not os.path.exists(os.path.join(image.path(), "animated/original.jpg")) 217 | -------------------------------------------------------------------------------- /tests/test_image_model.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from freezegun import freeze_time 4 | from mock import call, patch 5 | import pytest 6 | 7 | from django.core.cache import cache 8 | from django.core.files import File 9 | from django.db.models.fields.files import FieldFile 10 | from django.utils import timezone 11 | 12 | from betty.cropper.models import Image, Ratio 13 | 14 | 15 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 16 | 17 | 18 | @pytest.fixture() 19 | def image(request): 20 | image = Image.objects.create(name="Testing", width=512, height=512) 21 | lenna_path = os.path.join(TEST_DATA_PATH, 'Lenna.png') 22 | with open(lenna_path, "rb") as lenna: 23 | image.source.save('Lenna', File(lenna)) 24 | return image 25 | 26 | 27 | def make_some_crops(image, settings): 28 | settings.BETTY_RATIOS = ["1x1", "3x1", "16x9"] 29 | settings.BETTY_WIDTHS = [200, 400] 30 | settings.BETTY_CLIENT_ONLY_WIDTHS = [1200] 31 | 32 | # Crops that would be saved (if enabled) 33 | image.crop(ratio=Ratio('1x1'), width=200, extension='png') 34 | image.crop(ratio=Ratio('16x9'), width=400, extension='jpg') 35 | # Not saved to disk (not in WIDTH list) 36 | image.crop(ratio=Ratio('16x9'), width=401, extension='jpg') 37 | 38 | return image 39 | 40 | 41 | @pytest.mark.django_db 42 | @pytest.mark.usefixtures("clean_image_root") 43 | @pytest.mark.parametrize('save_crops', [True, False]) # Test setting enabled + disabled 44 | def test_image_clear_crops(image, settings, save_crops): 45 | 46 | settings.BETTY_SAVE_CROPS_TO_DISK = save_crops 47 | # Different than source path 48 | settings.BETTY_SAVE_CROPS_TO_DISK_ROOT = os.path.join(settings.BETTY_IMAGE_ROOT, 49 | 'local', 'crops') 50 | make_some_crops(image, settings) 51 | 52 | with patch('betty.cropper.models.settings.BETTY_CACHE_FLUSHER') as mock_flusher: 53 | with patch('shutil.rmtree') as mock_rmtree: 54 | 55 | image.clear_crops() 56 | 57 | # Flushes all supported crop combinations 58 | mock_flusher.assert_called_with([ 59 | '/images/{image_id}/{ratio}/{width}.{extension}'.format(image_id=image.id, 60 | width=width, 61 | ratio=ratio, 62 | extension=extension) 63 | for ratio in ['1x1', '3x1', '16x9', 'original'] 64 | for extension in ['png', 'jpg'] 65 | for width in [200, 400, 1200]]) 66 | 67 | assert mock_rmtree.called == save_crops 68 | if save_crops: 69 | # Filesystem deletes entire directories if they exist 70 | image_dir = os.path.join(settings.BETTY_SAVE_CROPS_TO_DISK_ROOT, str(image.id)) 71 | assert sorted(mock_rmtree.call_args_list) == sorted( 72 | [call(os.path.join(image_dir, '1x1')), 73 | call(os.path.join(image_dir, '16x9'))]) 74 | 75 | 76 | @pytest.mark.django_db 77 | @pytest.mark.usefixtures("clean_image_root") 78 | def test_animated_clear_crops(image, settings): 79 | 80 | settings.BETTY_SAVE_CROPS_TO_DISK = True 81 | 82 | make_some_crops(image, settings) 83 | 84 | # Generate animated "crops" too 85 | image.animated = True 86 | image.save() 87 | image.get_animated('gif') 88 | image.get_animated('jpg') 89 | 90 | with patch('betty.cropper.models.settings.BETTY_CACHE_FLUSHER') as mock_flusher: 91 | with patch('shutil.rmtree') as mock_rmtree: 92 | 93 | image.clear_crops() 94 | 95 | cleared_paths = mock_flusher.call_args[0][0] 96 | for ext in ['gif', 'jpg']: 97 | expected = '/images/{image_id}/animated/original.{ext}'.format(image_id=image.id, 98 | ext=ext) 99 | assert expected in cleared_paths 100 | 101 | # Filesystem deletes entire directories if they exist 102 | assert mock_rmtree.called 103 | removed_paths = [c[0][0] for c in mock_rmtree.call_args_list] 104 | image_dir = os.path.join(settings.BETTY_IMAGE_ROOT, str(image.id)) 105 | assert os.path.join(image_dir, 'animated') in removed_paths 106 | 107 | 108 | @pytest.mark.django_db 109 | def test_get_width(image): 110 | image.width = 0 111 | with patch('django.core.files.storage.open', create=True) as mock_open: 112 | mock_open.side_effect = lambda path, mode: open(path, mode) 113 | for _ in range(2): 114 | assert 512 == image.get_width() 115 | assert mock_open.call_count == 1 116 | 117 | 118 | @pytest.mark.django_db 119 | def test_get_height(image): 120 | image.height = 0 121 | with patch('django.core.files.storage.open', create=True) as mock_open: 122 | mock_open.side_effect = lambda path, mode: open(path, mode) 123 | for _ in range(2): 124 | assert 512 == image.get_height() 125 | assert mock_open.call_count == 1 126 | 127 | 128 | @pytest.mark.django_db 129 | def test_refresh_dimensions(image): 130 | image.height = 0 131 | image.width = 0 132 | with patch('django.core.files.storage.open', create=True) as mock_open: 133 | mock_open.side_effect = lambda path, mode: open(path, mode) 134 | image._refresh_dimensions() 135 | assert image.width == 512 136 | assert image.get_width() == 512 137 | 138 | 139 | @pytest.mark.django_db 140 | def test_last_modified_auto_now(): 141 | with freeze_time('2016-05-02 01:02:03'): 142 | image = Image.objects.create( 143 | name="Lenna.gif", 144 | width=512, 145 | height=512 146 | ) 147 | assert image.last_modified == timezone.datetime(2016, 5, 2, 1, 2, 3, tzinfo=timezone.utc) 148 | 149 | with freeze_time('2016-12-02 01:02:03'): 150 | image.save() 151 | assert image.last_modified == timezone.datetime(2016, 12, 2, 1, 2, 3, tzinfo=timezone.utc) 152 | 153 | 154 | @pytest.mark.django_db 155 | @pytest.mark.usefixtures("clean_image_root") 156 | def test_read_from_storage_cache(image, settings): 157 | 158 | settings.BETTY_CACHE_STORAGE_SEC = 3600 159 | 160 | cache_key = 'storage:' + image.source.name 161 | 162 | lenna_path = os.path.join(TEST_DATA_PATH, 'Lenna.png') 163 | with open(lenna_path, "rb") as lenna: 164 | expected_bytes = lenna.read() 165 | 166 | with patch.object(FieldFile, 'read') as mock_read: 167 | mock_read.side_effect = lambda: expected_bytes[:] 168 | 169 | # Check cache miss + fill, then cache hit 170 | with freeze_time('2016-07-06 00:00'): 171 | for _ in range(2): 172 | assert image.read_source_bytes().getvalue() == expected_bytes 173 | assert 1 == mock_read.call_count 174 | assert cache.get(cache_key) == expected_bytes 175 | 176 | # Check Expiration 177 | with freeze_time('2016-07-06 01:00'): 178 | assert not cache.get(cache_key) 179 | 180 | # Check cache re-fill 181 | assert image.read_source_bytes().getvalue() == expected_bytes 182 | assert 2 == mock_read.call_count 183 | assert cache.get(cache_key) == expected_bytes 184 | 185 | 186 | @pytest.mark.django_db 187 | @pytest.mark.usefixtures("clean_image_root") 188 | @pytest.mark.parametrize(['test_name', 'test_format'], 189 | # All supported formats 190 | [('Lenna.png', 'png'), 191 | ('Lenna.tiff', 'tiff'), 192 | ('Lenna.psd', 'psd'), 193 | ('animated.gif', 'gif'), 194 | ('Simpsons-Week_a.jpg', 'jpeg')]) 195 | def test_get_source_image(image, test_name, test_format): 196 | 197 | test_path = os.path.join(TEST_DATA_PATH, test_name) 198 | 199 | image = Image.objects.create_from_path(test_path) 200 | 201 | image_bytes, image_format = image.get_source() 202 | 203 | assert image_format == test_format 204 | 205 | with open(test_path, "rb") as image: 206 | assert image.read() == image_bytes 207 | -------------------------------------------------------------------------------- /tests/test_image_urls.py: -------------------------------------------------------------------------------- 1 | from betty.cropper.models import Image 2 | 3 | 4 | def test_id_string(): 5 | image = Image(id=123456) 6 | assert image.id_string == "1234/56" 7 | 8 | image = Image(id=123) 9 | assert image.id_string == "123" 10 | 11 | 12 | def test_image_url(): 13 | image = Image(id=123456) 14 | assert image.get_absolute_url() == "/images/1234/56/original/600.jpg" 15 | 16 | assert image.get_absolute_url(extension="png") == "/images/1234/56/original/600.png" 17 | assert image.get_absolute_url(width=900) == "/images/1234/56/original/900.jpg" 18 | assert image.get_absolute_url(ratio="16x9") == "/images/1234/56/16x9/600.jpg" 19 | assert image.get_absolute_url(extension="png", 20 | width=900, ratio="16x9") == "/images/1234/56/16x9/900.png" 21 | 22 | image = Image(id=123) 23 | assert image.get_absolute_url() == "/images/123/original/600.jpg" 24 | -------------------------------------------------------------------------------- /tests/test_imgmin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import psutil 3 | 4 | from betty.cropper.models import Image 5 | 6 | import pytest 7 | 8 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 9 | 10 | 11 | # mparent(2015-07-31): This is a workaround for the existing open_files() check to work under an 12 | # environment containing IPython dev tools. 13 | def get_open_files(process): 14 | files = process.open_files() 15 | # Ignore IPython dev files 16 | return [f for f in files 17 | if not f.path.endswith('/.ipython/profile_default/history.sqlite')] 18 | 19 | 20 | @pytest.mark.django_db 21 | @pytest.mark.usefixtures("clean_image_root") 22 | def test_imgmin_upload(settings): 23 | 24 | settings.BETTY_JPEG_QUALITY_RANGE = (60, 92) 25 | 26 | path = os.path.join(TEST_DATA_PATH, "Lenna.png") 27 | image = Image.objects.create_from_path(path) 28 | 29 | # Re-load the image, now that the task is done 30 | image = Image.objects.get(id=image.id) 31 | 32 | assert len(image.jpeg_quality_settings) > 1 33 | 34 | # Make sure that we closed all the files 35 | process = psutil.Process(os.getpid()) 36 | assert len(get_open_files(process)) == 0 37 | 38 | 39 | @pytest.mark.django_db 40 | @pytest.mark.usefixtures("clean_image_root") 41 | def test_imgmin_cartoon(settings): 42 | 43 | settings.BETTY_JPEG_QUALITY_RANGE = (60, 92) 44 | 45 | path = os.path.join(TEST_DATA_PATH, "Simpsons-Week_a.jpg") 46 | image = Image.objects.create_from_path(path) 47 | 48 | # Re-load the image, now that the task is done 49 | image = Image.objects.get(id=image.id) 50 | 51 | # this is a cartoon, so we should get 92 across the board 52 | assert image.jpeg_quality_settings == { 53 | "1200": 92, 54 | } 55 | 56 | # Make sure that we closed all the files 57 | process = psutil.Process(os.getpid()) 58 | print(get_open_files(process)) 59 | assert len(get_open_files(process)) == 0 60 | 61 | 62 | @pytest.mark.django_db 63 | @pytest.mark.usefixtures("clean_image_root") 64 | def test_imgmin_upload_lowquality(settings): 65 | 66 | settings.BETTY_JPEG_QUALITY_RANGE = (60, 92) 67 | 68 | path = os.path.join(TEST_DATA_PATH, "Sam_Hat1.jpg") 69 | image = Image.objects.create_from_path(path) 70 | 71 | # Re-load the image, now that the task is done 72 | image = Image.objects.get(id=image.id) 73 | 74 | # This image is already optimized, so this should do nothing. 75 | assert image.jpeg_quality_settings is None 76 | 77 | # Make sure that we closed all the files 78 | process = psutil.Process(os.getpid()) 79 | print(get_open_files(process)) 80 | assert len(get_open_files(process)) == 0 81 | -------------------------------------------------------------------------------- /tests/test_management_commands.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.core import management 3 | from django.core.management.base import CommandError 4 | from betty.authtoken.models import ApiToken 5 | 6 | import pytest 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_create_token(): 11 | token_count = ApiToken.objects.count() 12 | management.call_command("create_token") 13 | assert ApiToken.objects.count() == (token_count + 1) 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_create_specific_token(): 18 | management.call_command("create_token", "noop", "noop") 19 | qs = ApiToken.objects.filter(private_token="noop", public_token="noop") 20 | assert qs.count() == 1 21 | 22 | 23 | def test_command_error(): 24 | if django.VERSION[1] > 4: 25 | with pytest.raises(CommandError): 26 | management.call_command("create_token", "noop") 27 | 28 | with pytest.raises(CommandError): 29 | management.call_command("create_token", "noop", "noop", "noop") 30 | -------------------------------------------------------------------------------- /tests/test_source.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from freezegun import freeze_time 4 | import pytest 5 | 6 | from django.core.files import File 7 | 8 | from betty.cropper.models import Image 9 | 10 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 11 | 12 | 13 | @pytest.fixture() 14 | def image(request): 15 | image = Image.objects.create( 16 | name="Lenna.png", 17 | width=512, 18 | height=512 19 | ) 20 | 21 | lenna = File(open(os.path.join(TEST_DATA_PATH, "Lenna.png"), "rb")) 22 | image.source.save("Lenna.png", lenna) 23 | return image 24 | 25 | 26 | @freeze_time('2016-05-02 01:02:03') 27 | @pytest.mark.django_db 28 | @pytest.mark.usefixtures("clean_image_root") 29 | def test_get_png(settings, client, image): 30 | 31 | settings.BETTY_CACHE_CROP_SEC = 123 32 | 33 | res = client.get('/images/{}/source'.format(image.id)) 34 | assert res.status_code == 200 35 | assert res['Content-Type'] == 'image/png' 36 | assert res['Last-Modified'] == "Mon, 02 May 2016 01:02:03 GMT" 37 | assert res['Cache-Control'] == 'max-age=123' 38 | with open(os.path.join(TEST_DATA_PATH, "Lenna.png"), "rb") as lenna: 39 | assert res.content == lenna.read() 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_get_invalid_image(client): 44 | res = client.get('/images/1/source') 45 | assert res.status_code == 404 46 | 47 | 48 | @pytest.mark.django_db 49 | @pytest.mark.usefixtures("clean_image_root") 50 | def test_if_modified_since(settings, client, image): 51 | settings.BETTY_CACHE_CROP_SEC = 600 52 | res = client.get('/images/{}/source'.format(image.id), 53 | HTTP_IF_MODIFIED_SINCE="Sat, 01 May 2100 00:00:00 GMT") 54 | assert res.status_code == 304 55 | assert res['Cache-Control'] == 'max-age=600' 56 | assert not res.content # Empty content 57 | -------------------------------------------------------------------------------- /tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from mock import patch 5 | import pytest 6 | 7 | from inmemorystorage.storage import InMemoryStorage 8 | 9 | TEST_DATA_PATH = os.path.join(os.path.dirname(__file__), 'images') 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_alternate_storage(admin_client, settings): 14 | """Verify can plugin alternate storage backend""" 15 | 16 | settings.BETTY_IMAGE_ROOT = 'images' 17 | 18 | storage = InMemoryStorage() 19 | with patch('django.db.models.fields.files.default_storage._wrapped', storage): 20 | 21 | # Create Image 22 | path = os.path.join(TEST_DATA_PATH, 'Lenna.png') 23 | with open(path, "rb") as image: 24 | 25 | resp = admin_client.post('/images/api/new', {"image": image}) 26 | assert resp.status_code == 200 27 | image_id = json.loads(resp.content.decode("utf-8"))['id'] 28 | 29 | image.seek(0) 30 | image_data = image.read() 31 | storage_data = storage.filesystem.open('images/{}/Lenna.png'.format(image_id)).read() 32 | assert image_data == storage_data 33 | assert storage.filesystem.exists('images/{}/optimized.png'.format(image_id)) 34 | 35 | # Delete Image 36 | resp = admin_client.post("/images/api/{0}".format(image_id), 37 | REQUEST_METHOD="DELETE") 38 | 39 | assert not storage.filesystem.exists('images/{}/Lenna.png'.format(image_id)) 40 | assert not storage.filesystem.exists('images/{}/optimized.png'.format(image_id)) 41 | --------------------------------------------------------------------------------