From 74488bcce98f59ac255fa342ff61def2251ebdb5 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Marek=20St=C4=99pniowski?= Date: Thu, 17 Dec 2009 16:42:07 +0100 Subject: [PATCH] Dodanie sorl.thumbnail i generowanie miniaturek logo na potrzeby aplikacji sponsors. --- apps/sorl/__init__.py | 0 apps/sorl/thumbnail/__init__.py | 0 apps/sorl/thumbnail/base.py | 285 ++++++++++++++++ apps/sorl/thumbnail/defaults.py | 15 + apps/sorl/thumbnail/fields.py | 228 +++++++++++++ apps/sorl/thumbnail/main.py | 115 +++++++ apps/sorl/thumbnail/management/__init__.py | 0 .../thumbnail/management/commands/__init__.py | 0 .../management/commands/thumbnail_cleanup.py | 75 +++++ apps/sorl/thumbnail/models.py | 1 + apps/sorl/thumbnail/processors.py | 130 ++++++++ apps/sorl/thumbnail/templatetags/__init__.py | 0 apps/sorl/thumbnail/templatetags/thumbnail.py | 251 ++++++++++++++ apps/sorl/thumbnail/tests/__init__.py | 16 + apps/sorl/thumbnail/tests/base.py | 105 ++++++ apps/sorl/thumbnail/tests/classes.py | 175 ++++++++++ apps/sorl/thumbnail/tests/fields.py | 131 ++++++++ apps/sorl/thumbnail/tests/templatetags.py | 312 ++++++++++++++++++ apps/sorl/thumbnail/tests/utils.py | 149 +++++++++ apps/sorl/thumbnail/utils.py | 170 ++++++++++ apps/sponsors/models.py | 10 +- apps/sponsors/processors.py | 13 + .../static/sponsors/css/footer_admin.css | 9 +- .../static/sponsors/js/footer_admin.js | 4 +- apps/sponsors/templates/sponsors/page.html | 6 +- apps/sponsors/widgets.py | 4 +- wolnelektury/settings.py | 13 + .../static/sponsors/css/footer_admin.css | 9 +- .../static/sponsors/js/footer_admin.js | 4 +- 29 files changed, 2212 insertions(+), 18 deletions(-) create mode 100755 apps/sorl/__init__.py create mode 100644 apps/sorl/thumbnail/__init__.py create mode 100755 apps/sorl/thumbnail/base.py create mode 100644 apps/sorl/thumbnail/defaults.py create mode 100644 apps/sorl/thumbnail/fields.py create mode 100644 apps/sorl/thumbnail/main.py create mode 100644 apps/sorl/thumbnail/management/__init__.py create mode 100644 apps/sorl/thumbnail/management/commands/__init__.py create mode 100644 apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py create mode 100644 apps/sorl/thumbnail/models.py create mode 100644 apps/sorl/thumbnail/processors.py create mode 100644 apps/sorl/thumbnail/templatetags/__init__.py create mode 100755 apps/sorl/thumbnail/templatetags/thumbnail.py create mode 100644 apps/sorl/thumbnail/tests/__init__.py create mode 100644 apps/sorl/thumbnail/tests/base.py create mode 100644 apps/sorl/thumbnail/tests/classes.py create mode 100644 apps/sorl/thumbnail/tests/fields.py create mode 100644 apps/sorl/thumbnail/tests/templatetags.py create mode 100644 apps/sorl/thumbnail/tests/utils.py create mode 100644 apps/sorl/thumbnail/utils.py create mode 100644 apps/sponsors/processors.py diff --git a/apps/sorl/__init__.py b/apps/sorl/__init__.py new file mode 100755 index 000000000..e69de29bb diff --git a/apps/sorl/thumbnail/__init__.py b/apps/sorl/thumbnail/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/sorl/thumbnail/base.py b/apps/sorl/thumbnail/base.py new file mode 100755 index 000000000..24f4d9735 --- /dev/null +++ b/apps/sorl/thumbnail/base.py @@ -0,0 +1,285 @@ +import os +from os.path import isfile, isdir, getmtime, dirname, splitext, getsize +from tempfile import mkstemp +from shutil import copyfile + +from PIL import Image + +from sorl.thumbnail import defaults +from sorl.thumbnail.processors import get_valid_options, dynamic_import + + +class ThumbnailException(Exception): + # Stop Django templates from choking if something goes wrong. + silent_variable_failure = True + + +class Thumbnail(object): + imagemagick_file_types = defaults.IMAGEMAGICK_FILE_TYPES + + def __init__(self, source, requested_size, opts=None, quality=85, + dest=None, convert_path=defaults.CONVERT, + wvps_path=defaults.WVPS, processors=None): + # Paths to external commands + self.convert_path = convert_path + self.wvps_path = wvps_path + # Absolute paths to files + self.source = source + self.dest = dest + + # Thumbnail settings + try: + x, y = [int(v) for v in requested_size] + except (TypeError, ValueError): + raise TypeError('Thumbnail received invalid value for size ' + 'argument: %s' % repr(requested_size)) + else: + self.requested_size = (x, y) + try: + self.quality = int(quality) + if not 0 < quality <= 100: + raise ValueError + except (TypeError, ValueError): + raise TypeError('Thumbnail received invalid value for quality ' + 'argument: %r' % quality) + + # Processors + if processors is None: + processors = dynamic_import(defaults.PROCESSORS) + self.processors = processors + + # Handle old list format for opts. + opts = opts or {} + if isinstance(opts, (list, tuple)): + opts = dict([(opt, None) for opt in opts]) + + # Set Thumbnail opt(ion)s + VALID_OPTIONS = get_valid_options(processors) + for opt in opts: + if not opt in VALID_OPTIONS: + raise TypeError('Thumbnail received an invalid option: %s' + % opt) + self.opts = opts + + if self.dest is not None: + self.generate() + + def generate(self): + """ + Generates the thumbnail if it doesn't exist or if the file date of the + source file is newer than that of the thumbnail. + """ + # Ensure dest(ination) attribute is set + if not self.dest: + raise ThumbnailException("No destination filename set.") + + if not isinstance(self.dest, basestring): + # We'll assume dest is a file-like instance if it exists but isn't + # a string. + self._do_generate() + elif not isfile(self.dest) or (self.source_exists and + getmtime(self.source) > getmtime(self.dest)): + + # Ensure the directory exists + directory = dirname(self.dest) + if directory and not isdir(directory): + os.makedirs(directory) + + self._do_generate() + + def _check_source_exists(self): + """ + Ensure the source file exists. If source is not a string then it is + assumed to be a file-like instance which "exists". + """ + if not hasattr(self, '_source_exists'): + self._source_exists = (self.source and + (not isinstance(self.source, basestring) or + isfile(self.source))) + return self._source_exists + source_exists = property(_check_source_exists) + + def _get_source_filetype(self): + """ + Set the source filetype. First it tries to use magic and + if import error it will just use the extension + """ + if not hasattr(self, '_source_filetype'): + if not isinstance(self.source, basestring): + # Assuming a file-like object - we won't know it's type. + return None + try: + import magic + except ImportError: + self._source_filetype = splitext(self.source)[1].lower().\ + replace('.', '').replace('jpeg', 'jpg') + else: + m = magic.open(magic.MAGIC_NONE) + m.load() + ftype = m.file(self.source) + if ftype.find('Microsoft Office Document') != -1: + self._source_filetype = 'doc' + elif ftype.find('PDF document') != -1: + self._source_filetype = 'pdf' + elif ftype.find('JPEG') != -1: + self._source_filetype = 'jpg' + else: + self._source_filetype = ftype + return self._source_filetype + source_filetype = property(_get_source_filetype) + + # data property is the image data of the (generated) thumbnail + def _get_data(self): + if not hasattr(self, '_data'): + try: + self._data = Image.open(self.dest) + except IOError, detail: + raise ThumbnailException(detail) + return self._data + + def _set_data(self, im): + self._data = im + data = property(_get_data, _set_data) + + # source_data property is the image data from the source file + def _get_source_data(self): + if not hasattr(self, '_source_data'): + if not self.source_exists: + raise ThumbnailException("Source file: '%s' does not exist." % + self.source) + if self.source_filetype == 'doc': + self._convert_wvps(self.source) + elif self.source_filetype in self.imagemagick_file_types: + self._convert_imagemagick(self.source) + else: + self.source_data = self.source + return self._source_data + + def _set_source_data(self, image): + if isinstance(image, Image.Image): + self._source_data = image + else: + try: + self._source_data = Image.open(image) + except IOError, detail: + raise ThumbnailException("%s: %s" % (detail, image)) + except MemoryError: + raise ThumbnailException("Memory Error: %s" % image) + source_data = property(_get_source_data, _set_source_data) + + def _convert_wvps(self, filename): + try: + import subprocess + except ImportError: + raise ThumbnailException('wvps requires the Python 2.4 subprocess ' + 'package.') + tmp = mkstemp('.ps')[1] + try: + p = subprocess.Popen((self.wvps_path, filename, tmp), + stdout=subprocess.PIPE) + p.wait() + except OSError, detail: + os.remove(tmp) + raise ThumbnailException('wvPS error: %s' % detail) + self._convert_imagemagick(tmp) + os.remove(tmp) + + def _convert_imagemagick(self, filename): + try: + import subprocess + except ImportError: + raise ThumbnailException('imagemagick requires the Python 2.4 ' + 'subprocess package.') + tmp = mkstemp('.png')[1] + if 'crop' in self.opts or 'autocrop' in self.opts: + x, y = [d * 3 for d in self.requested_size] + else: + x, y = self.requested_size + try: + p = subprocess.Popen((self.convert_path, '-size', '%sx%s' % (x, y), + '-antialias', '-colorspace', 'rgb', '-format', 'PNG24', + '%s[0]' % filename, tmp), stdout=subprocess.PIPE) + p.wait() + except OSError, detail: + os.remove(tmp) + raise ThumbnailException('ImageMagick error: %s' % detail) + self.source_data = tmp + os.remove(tmp) + + def _do_generate(self): + """ + Generates the thumbnail image. + + This a semi-private method so it isn't directly available to template + authors if this object is passed to the template context. + """ + im = self.source_data + + for processor in self.processors: + im = processor(im, self.requested_size, self.opts) + + self.data = im + + filelike = not isinstance(self.dest, basestring) + if not filelike: + dest_extension = os.path.splitext(self.dest)[1][1:] + format = None + else: + dest_extension = None + format = 'JPEG' + if (self.source_filetype and self.source_filetype == dest_extension and + self.source_data == self.data): + copyfile(self.source, self.dest) + else: + try: + im.save(self.dest, format=format, quality=self.quality, + optimize=1) + except IOError: + # Try again, without optimization (PIL can't optimize an image + # larger than ImageFile.MAXBLOCK, which is 64k by default) + try: + im.save(self.dest, format=format, quality=self.quality) + except IOError, detail: + raise ThumbnailException(detail) + + if filelike: + self.dest.seek(0) + + # Some helpful methods + + def _dimension(self, axis): + if self.dest is None: + return None + return self.data.size[axis] + + def width(self): + return self._dimension(0) + + def height(self): + return self._dimension(1) + + def _get_filesize(self): + if self.dest is None: + return None + if not hasattr(self, '_filesize'): + self._filesize = getsize(self.dest) + return self._filesize + filesize = property(_get_filesize) + + def _source_dimension(self, axis): + if self.source_filetype in ['pdf', 'doc']: + return None + else: + return self.source_data.size[axis] + + def source_width(self): + return self._source_dimension(0) + + def source_height(self): + return self._source_dimension(1) + + def _get_source_filesize(self): + if not hasattr(self, '_source_filesize'): + self._source_filesize = getsize(self.source) + return self._source_filesize + source_filesize = property(_get_source_filesize) diff --git a/apps/sorl/thumbnail/defaults.py b/apps/sorl/thumbnail/defaults.py new file mode 100644 index 000000000..b4ae142b1 --- /dev/null +++ b/apps/sorl/thumbnail/defaults.py @@ -0,0 +1,15 @@ +DEBUG = False +BASEDIR = '' +SUBDIR = '' +PREFIX = '' +QUALITY = 85 +CONVERT = '/usr/bin/convert' +WVPS = '/usr/bin/wvPS' +EXTENSION = 'jpg' +PROCESSORS = ( + 'sorl.thumbnail.processors.colorspace', + 'sorl.thumbnail.processors.autocrop', + 'sorl.thumbnail.processors.scale_and_crop', + 'sorl.thumbnail.processors.filters', +) +IMAGEMAGICK_FILE_TYPES = ('eps', 'pdf', 'psd') diff --git a/apps/sorl/thumbnail/fields.py b/apps/sorl/thumbnail/fields.py new file mode 100644 index 000000000..1b5274340 --- /dev/null +++ b/apps/sorl/thumbnail/fields.py @@ -0,0 +1,228 @@ +from UserDict import DictMixin +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +from django.db.models.fields.files import ImageField, ImageFieldFile +from django.core.files.base import ContentFile +from django.utils.safestring import mark_safe +from django.utils.html import escape + +from sorl.thumbnail.base import Thumbnail +from sorl.thumbnail.main import DjangoThumbnail, build_thumbnail_name +from sorl.thumbnail.utils import delete_thumbnails + + +REQUIRED_ARGS = ('size',) +ALL_ARGS = { + 'size': 'requested_size', + 'options': 'opts', + 'quality': 'quality', + 'basedir': 'basedir', + 'subdir': 'subdir', + 'prefix': 'prefix', + 'extension': 'extension', +} +BASE_ARGS = { + 'size': 'requested_size', + 'options': 'opts', + 'quality': 'quality', +} +TAG_HTML = '' + + +class ThumbsDict(object, DictMixin): + def __init__(self, descriptor): + super(ThumbsDict, self).__init__() + self.descriptor = descriptor + + def keys(self): + return self.descriptor.field.extra_thumbnails.keys() + + +class LazyThumbs(ThumbsDict): + def __init__(self, *args, **kwargs): + super(LazyThumbs, self).__init__(*args, **kwargs) + self.cached = {} + + def __getitem__(self, key): + thumb = self.cached.get(key) + if not thumb: + args = self.descriptor.field.extra_thumbnails[key] + thumb = self.descriptor._build_thumbnail(args) + self.cached[key] = thumb + return thumb + + def keys(self): + return self.descriptor.field.extra_thumbnails.keys() + + +class ThumbTags(ThumbsDict): + def __getitem__(self, key): + thumb = self.descriptor.extra_thumbnails[key] + return self.descriptor._build_thumbnail_tag(thumb) + + +class BaseThumbnailFieldFile(ImageFieldFile): + def _build_thumbnail(self, args): + # Build the DjangoThumbnail kwargs. + kwargs = {} + for k, v in args.items(): + kwargs[ALL_ARGS[k]] = v + # Build the destination filename and return the thumbnail. + name_kwargs = {} + for key in ['size', 'options', 'quality', 'basedir', 'subdir', + 'prefix', 'extension']: + name_kwargs[key] = args.get(key) + source = getattr(self.instance, self.field.name) + dest = build_thumbnail_name(source.name, **name_kwargs) + return DjangoThumbnail(source, relative_dest=dest, **kwargs) + + def _build_thumbnail_tag(self, thumb): + opts = dict(src=escape(thumb), width=thumb.width(), + height=thumb.height()) + return mark_safe(self.field.thumbnail_tag % opts) + + def _get_extra_thumbnails(self): + if self.field.extra_thumbnails is None: + return None + if not hasattr(self, '_extra_thumbnails'): + self._extra_thumbnails = LazyThumbs(self) + return self._extra_thumbnails + extra_thumbnails = property(_get_extra_thumbnails) + + def _get_extra_thumbnails_tag(self): + if self.field.extra_thumbnails is None: + return None + return ThumbTags(self) + extra_thumbnails_tag = property(_get_extra_thumbnails_tag) + + def save(self, *args, **kwargs): + # Optionally generate the thumbnails after the image is saved. + super(BaseThumbnailFieldFile, self).save(*args, **kwargs) + if self.field.generate_on_save: + self.generate_thumbnails() + + def delete(self, *args, **kwargs): + # Delete any thumbnails too (and not just ones defined here in case + # the {% thumbnail %} tag was used or the thumbnail sizes changed). + relative_source_path = getattr(self.instance, self.field.name).name + delete_thumbnails(relative_source_path) + super(BaseThumbnailFieldFile, self).delete(*args, **kwargs) + + def generate_thumbnails(self): + # Getting the thumbs generates them. + if self.extra_thumbnails: + self.extra_thumbnails.values() + + +class ImageWithThumbnailsFieldFile(BaseThumbnailFieldFile): + def _get_thumbnail(self): + return self._build_thumbnail(self.field.thumbnail) + thumbnail = property(_get_thumbnail) + + def _get_thumbnail_tag(self): + return self._build_thumbnail_tag(self.thumbnail) + thumbnail_tag = property(_get_thumbnail_tag) + + def generate_thumbnails(self, *args, **kwargs): + self.thumbnail.generate() + Super = super(ImageWithThumbnailsFieldFile, self) + return Super.generate_thumbnails(*args, **kwargs) + + +class ThumbnailFieldFile(BaseThumbnailFieldFile): + def save(self, name, content, *args, **kwargs): + new_content = StringIO() + # Build the Thumbnail kwargs. + thumbnail_kwargs = {} + for k, argk in BASE_ARGS.items(): + if not k in self.field.thumbnail: + continue + thumbnail_kwargs[argk] = self.field.thumbnail[k] + Thumbnail(source=content, dest=new_content, **thumbnail_kwargs) + new_content = ContentFile(new_content.read()) + super(ThumbnailFieldFile, self).save(name, new_content, *args, + **kwargs) + + def _get_thumbnail_tag(self): + opts = dict(src=escape(self.url), width=self.width, + height=self.height) + return mark_safe(self.field.thumbnail_tag % opts) + thumbnail_tag = property(_get_thumbnail_tag) + + +class BaseThumbnailField(ImageField): + def __init__(self, *args, **kwargs): + # The new arguments for this field aren't explicitly defined so that + # users can still use normal ImageField positional arguments. + self.extra_thumbnails = kwargs.pop('extra_thumbnails', None) + self.thumbnail_tag = kwargs.pop('thumbnail_tag', TAG_HTML) + self.generate_on_save = kwargs.pop('generate_on_save', False) + + super(BaseThumbnailField, self).__init__(*args, **kwargs) + _verify_thumbnail_attrs(self.thumbnail) + if self.extra_thumbnails: + for extra, attrs in self.extra_thumbnails.items(): + name = "%r of 'extra_thumbnails'" + _verify_thumbnail_attrs(attrs, name) + + def south_field_triple(self): + """ + Return a suitable description of this field for South. + """ + # We'll just introspect ourselves, since we inherit. + from south.modelsinspector import introspector + field_class = "django.db.models.fields.files.ImageField" + args, kwargs = introspector(self) + # That's our definition! + return (field_class, args, kwargs) + + +class ImageWithThumbnailsField(BaseThumbnailField): + """ + photo = ImageWithThumbnailsField( + upload_to='uploads', + thumbnail={'size': (80, 80), 'options': ('crop', 'upscale'), + 'extension': 'png'}, + extra_thumbnails={ + 'admin': {'size': (70, 50), 'options': ('sharpen',)}, + } + ) + """ + attr_class = ImageWithThumbnailsFieldFile + + def __init__(self, *args, **kwargs): + self.thumbnail = kwargs.pop('thumbnail', None) + super(ImageWithThumbnailsField, self).__init__(*args, **kwargs) + + +class ThumbnailField(BaseThumbnailField): + """ + avatar = ThumbnailField( + upload_to='uploads', + size=(200, 200), + options=('crop',), + extra_thumbnails={ + 'admin': {'size': (70, 50), 'options': (crop, 'sharpen')}, + } + ) + """ + attr_class = ThumbnailFieldFile + + def __init__(self, *args, **kwargs): + self.thumbnail = {} + for attr in ALL_ARGS: + if attr in kwargs: + self.thumbnail[attr] = kwargs.pop(attr) + super(ThumbnailField, self).__init__(*args, **kwargs) + + +def _verify_thumbnail_attrs(attrs, name="'thumbnail'"): + for arg in REQUIRED_ARGS: + if arg not in attrs: + raise TypeError('Required attr %r missing in %s arg' % (arg, name)) + for attr in attrs: + if attr not in ALL_ARGS: + raise TypeError('Invalid attr %r found in %s arg' % (arg, name)) diff --git a/apps/sorl/thumbnail/main.py b/apps/sorl/thumbnail/main.py new file mode 100644 index 000000000..a59b64f13 --- /dev/null +++ b/apps/sorl/thumbnail/main.py @@ -0,0 +1,115 @@ +import os + +from django.conf import settings +from django.utils.encoding import iri_to_uri, force_unicode + +from sorl.thumbnail.base import Thumbnail +from sorl.thumbnail.processors import dynamic_import +from sorl.thumbnail import defaults + + +def get_thumbnail_setting(setting, override=None): + """ + Get a thumbnail setting from Django settings module, falling back to the + default. + + If override is not None, it will be used instead of the setting. + """ + if override is not None: + return override + if hasattr(settings, 'THUMBNAIL_%s' % setting): + return getattr(settings, 'THUMBNAIL_%s' % setting) + else: + return getattr(defaults, setting) + + +def build_thumbnail_name(source_name, size, options=None, + quality=None, basedir=None, subdir=None, prefix=None, + extension=None): + quality = get_thumbnail_setting('QUALITY', quality) + basedir = get_thumbnail_setting('BASEDIR', basedir) + subdir = get_thumbnail_setting('SUBDIR', subdir) + prefix = get_thumbnail_setting('PREFIX', prefix) + extension = get_thumbnail_setting('EXTENSION', extension) + path, filename = os.path.split(source_name) + basename, ext = os.path.splitext(filename) + name = '%s%s' % (basename, ext.replace(os.extsep, '_')) + size = '%sx%s' % tuple(size) + + # Handle old list format for opts. + options = options or {} + if isinstance(options, (list, tuple)): + options = dict([(opt, None) for opt in options]) + + opts = options.items() + opts.sort() # options are sorted so the filename is consistent + opts = ['%s_' % (v is not None and '%s-%s' % (k, v) or k) + for k, v in opts] + opts = ''.join(opts) + extension = extension and '.%s' % extension + thumbnail_filename = '%s%s_%s_%sq%s%s' % (prefix, name, size, opts, + quality, extension) + return os.path.join(basedir, path, subdir, thumbnail_filename) + + +class DjangoThumbnail(Thumbnail): + imagemagick_file_types = get_thumbnail_setting('IMAGEMAGICK_FILE_TYPES') + + def __init__(self, relative_source, requested_size, opts=None, + quality=None, basedir=None, subdir=None, prefix=None, + relative_dest=None, processors=None, extension=None): + relative_source = force_unicode(relative_source) + # Set the absolute filename for the source file + source = self._absolute_path(relative_source) + + quality = get_thumbnail_setting('QUALITY', quality) + convert_path = get_thumbnail_setting('CONVERT') + wvps_path = get_thumbnail_setting('WVPS') + if processors is None: + processors = dynamic_import(get_thumbnail_setting('PROCESSORS')) + + # Call super().__init__ now to set the opts attribute. generate() won't + # get called because we are not setting the dest attribute yet. + super(DjangoThumbnail, self).__init__(source, requested_size, + opts=opts, quality=quality, convert_path=convert_path, + wvps_path=wvps_path, processors=processors) + + # Get the relative filename for the thumbnail image, then set the + # destination filename + if relative_dest is None: + relative_dest = \ + self._get_relative_thumbnail(relative_source, basedir=basedir, + subdir=subdir, prefix=prefix, + extension=extension) + filelike = not isinstance(relative_dest, basestring) + if filelike: + self.dest = relative_dest + else: + self.dest = self._absolute_path(relative_dest) + + # Call generate now that the dest attribute has been set + self.generate() + + # Set the relative & absolute url to the thumbnail + if not filelike: + self.relative_url = \ + iri_to_uri('/'.join(relative_dest.split(os.sep))) + self.absolute_url = '%s%s' % (settings.MEDIA_URL, + self.relative_url) + + def _get_relative_thumbnail(self, relative_source, + basedir=None, subdir=None, prefix=None, + extension=None): + """ + Returns the thumbnail filename including relative path. + """ + return build_thumbnail_name(relative_source, self.requested_size, + self.opts, self.quality, basedir, subdir, + prefix, extension) + + def _absolute_path(self, filename): + absolute_filename = os.path.join(settings.MEDIA_ROOT, filename) + return absolute_filename.encode(settings.FILE_CHARSET) + + def __unicode__(self): + return self.absolute_url diff --git a/apps/sorl/thumbnail/management/__init__.py b/apps/sorl/thumbnail/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/sorl/thumbnail/management/commands/__init__.py b/apps/sorl/thumbnail/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py b/apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py new file mode 100644 index 000000000..690c42c78 --- /dev/null +++ b/apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py @@ -0,0 +1,75 @@ +import os +import re +from django.db import models +from django.conf import settings +from django.core.management.base import NoArgsCommand +from sorl.thumbnail.main import get_thumbnail_setting + + +try: + set +except NameError: + from sets import Set as set # For Python 2.3 + +thumb_re = re.compile(r'^%s(.*)_\d{1,}x\d{1,}_[-\w]*q([1-9]\d?|100)\.jpg' % + get_thumbnail_setting('PREFIX')) + + +def get_thumbnail_path(path): + basedir = get_thumbnail_setting('BASEDIR') + subdir = get_thumbnail_setting('SUBDIR') + return os.path.join(basedir, path, subdir) + + +def clean_up(): + paths = set() + for app in models.get_apps(): + model_list = models.get_models(app) + for model in model_list: + for field in model._meta.fields: + if isinstance(field, models.ImageField): + #TODO: take care of date formatted and callable upload_to. + if (not callable(field.upload_to) and + field.upload_to.find("%") == -1): + paths = paths.union((field.upload_to,)) + paths = list(paths) + for path in paths: + thumbnail_path = get_thumbnail_path(path) + try: + file_list = os.listdir(os.path.join(settings.MEDIA_ROOT, + thumbnail_path)) + except OSError: + continue # Dir doesn't exists, no thumbnails here. + for fn in file_list: + m = thumb_re.match(fn) + if m: + # Due to that the naming of thumbnails replaces the dot before + # extension with an underscore we have 2 possibilities for the + # original filename. If either present we do not delete + # suspected thumbnail. + # org_fn is the expected original filename w/o extension + # org_fn_alt is the expected original filename with extension + org_fn = m.group(1) + org_fn_exists = os.path.isfile( + os.path.join(settings.MEDIA_ROOT, path, org_fn)) + + usc_pos = org_fn.rfind("_") + if usc_pos != -1: + org_fn_alt = "%s.%s" % (org_fn[0:usc_pos], + org_fn[usc_pos+1:]) + org_fn_alt_exists = os.path.isfile( + os.path.join(settings.MEDIA_ROOT, path, org_fn_alt)) + else: + org_fn_alt_exists = False + if not org_fn_exists and not org_fn_alt_exists: + del_me = os.path.join(settings.MEDIA_ROOT, + thumbnail_path, fn) + os.remove(del_me) + + +class Command(NoArgsCommand): + help = "Deletes thumbnails that no longer have an original file." + requires_model_validation = False + + def handle_noargs(self, **options): + clean_up() diff --git a/apps/sorl/thumbnail/models.py b/apps/sorl/thumbnail/models.py new file mode 100644 index 000000000..ec325fd58 --- /dev/null +++ b/apps/sorl/thumbnail/models.py @@ -0,0 +1 @@ +# Needs a models.py file so that tests are picked up. diff --git a/apps/sorl/thumbnail/processors.py b/apps/sorl/thumbnail/processors.py new file mode 100644 index 000000000..a6c174162 --- /dev/null +++ b/apps/sorl/thumbnail/processors.py @@ -0,0 +1,130 @@ +from PIL import Image, ImageFilter, ImageChops +from sorl.thumbnail import utils +import re + + +def dynamic_import(names): + imported = [] + for name in names: + # Use rfind rather than rsplit for Python 2.3 compatibility. + lastdot = name.rfind('.') + modname, attrname = name[:lastdot], name[lastdot + 1:] + mod = __import__(modname, {}, {}, ['']) + imported.append(getattr(mod, attrname)) + return imported + + +def get_valid_options(processors): + """ + Returns a list containing unique valid options from a list of processors + in correct order. + """ + valid_options = [] + for processor in processors: + if hasattr(processor, 'valid_options'): + valid_options.extend([opt for opt in processor.valid_options + if opt not in valid_options]) + return valid_options + + +def colorspace(im, requested_size, opts): + if 'bw' in opts and im.mode != "L": + im = im.convert("L") + elif im.mode not in ("L", "RGB", "RGBA"): + im = im.convert("RGB") + return im +colorspace.valid_options = ('bw',) + + +def autocrop(im, requested_size, opts): + if 'autocrop' in opts: + bw = im.convert("1") + bw = bw.filter(ImageFilter.MedianFilter) + # white bg + bg = Image.new("1", im.size, 255) + diff = ImageChops.difference(bw, bg) + bbox = diff.getbbox() + if bbox: + im = im.crop(bbox) + return im +autocrop.valid_options = ('autocrop',) + + +def scale_and_crop(im, requested_size, opts): + x, y = [float(v) for v in im.size] + xr, yr = [float(v) for v in requested_size] + + if 'crop' in opts or 'max' in opts: + r = max(xr / x, yr / y) + else: + r = min(xr / x, yr / y) + + if r < 1.0 or (r > 1.0 and 'upscale' in opts): + im = im.resize((int(x * r), int(y * r)), resample=Image.ANTIALIAS) + + crop = opts.get('crop') or 'crop' in opts + if crop: + # Difference (for x and y) between new image size and requested size. + x, y = [float(v) for v in im.size] + dx, dy = (x - min(x, xr)), (y - min(y, yr)) + if dx or dy: + # Center cropping (default). + ex, ey = dx / 2, dy / 2 + box = [ex, ey, x - ex, y - ey] + # See if an edge cropping argument was provided. + edge_crop = (isinstance(crop, basestring) and + re.match(r'(?:(-?)(\d+))?,(?:(-?)(\d+))?$', crop)) + if edge_crop and filter(None, edge_crop.groups()): + x_right, x_crop, y_bottom, y_crop = edge_crop.groups() + if x_crop: + offset = min(x * int(x_crop) / 100, dx) + if x_right: + box[0] = dx - offset + box[2] = x - offset + else: + box[0] = offset + box[2] = x - (dx - offset) + if y_crop: + offset = min(y * int(y_crop) / 100, dy) + if y_bottom: + box[1] = dy - offset + box[3] = y - offset + else: + box[1] = offset + box[3] = y - (dy - offset) + # See if the image should be "smart cropped". + elif crop == 'smart': + left = top = 0 + right, bottom = x, y + while dx: + slice = min(dx, 10) + l_sl = im.crop((0, 0, slice, y)) + r_sl = im.crop((x - slice, 0, x, y)) + if utils.image_entropy(l_sl) >= utils.image_entropy(r_sl): + right -= slice + else: + left += slice + dx -= slice + while dy: + slice = min(dy, 10) + t_sl = im.crop((0, 0, x, slice)) + b_sl = im.crop((0, y - slice, x, y)) + if utils.image_entropy(t_sl) >= utils.image_entropy(b_sl): + bottom -= slice + else: + top += slice + dy -= slice + box = (left, top, right, bottom) + # Finally, crop the image! + im = im.crop([int(v) for v in box]) + return im +scale_and_crop.valid_options = ('crop', 'upscale', 'max') + + +def filters(im, requested_size, opts): + if 'detail' in opts: + im = im.filter(ImageFilter.DETAIL) + if 'sharpen' in opts: + im = im.filter(ImageFilter.SHARPEN) + return im +filters.valid_options = ('detail', 'sharpen') diff --git a/apps/sorl/thumbnail/templatetags/__init__.py b/apps/sorl/thumbnail/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/sorl/thumbnail/templatetags/thumbnail.py b/apps/sorl/thumbnail/templatetags/thumbnail.py new file mode 100755 index 000000000..e7c2177ee --- /dev/null +++ b/apps/sorl/thumbnail/templatetags/thumbnail.py @@ -0,0 +1,251 @@ +import re +import math +from django.template import Library, Node, VariableDoesNotExist, \ + TemplateSyntaxError +from sorl.thumbnail.main import DjangoThumbnail, get_thumbnail_setting +from sorl.thumbnail.processors import dynamic_import, get_valid_options +from sorl.thumbnail.utils import split_args + +register = Library() + +size_pat = re.compile(r'(\d+)x(\d+)$') + +filesize_formats = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +filesize_long_formats = { + 'k': 'kilo', 'M': 'mega', 'G': 'giga', 'T': 'tera', 'P': 'peta', + 'E': 'exa', 'Z': 'zetta', 'Y': 'yotta', +} + +try: + PROCESSORS = dynamic_import(get_thumbnail_setting('PROCESSORS')) + VALID_OPTIONS = get_valid_options(PROCESSORS) +except: + if get_thumbnail_setting('DEBUG'): + raise + else: + PROCESSORS = [] + VALID_OPTIONS = [] +TAG_SETTINGS = ['quality'] + + +class ThumbnailNode(Node): + def __init__(self, source_var, size_var, opts=None, + context_name=None, **kwargs): + self.source_var = source_var + self.size_var = size_var + self.opts = opts + self.context_name = context_name + self.kwargs = kwargs + + def render(self, context): + # Note that this isn't a global constant because we need to change the + # value for tests. + DEBUG = get_thumbnail_setting('DEBUG') + try: + # A file object will be allowed in DjangoThumbnail class + relative_source = self.source_var.resolve(context) + except VariableDoesNotExist: + if DEBUG: + raise VariableDoesNotExist("Variable '%s' does not exist." % + self.source_var) + else: + relative_source = None + try: + requested_size = self.size_var.resolve(context) + except VariableDoesNotExist: + if DEBUG: + raise TemplateSyntaxError("Size argument '%s' is not a" + " valid size nor a valid variable." % self.size_var) + else: + requested_size = None + # Size variable can be either a tuple/list of two integers or a valid + # string, only the string is checked. + else: + if isinstance(requested_size, basestring): + m = size_pat.match(requested_size) + if m: + requested_size = (int(m.group(1)), int(m.group(2))) + elif DEBUG: + raise TemplateSyntaxError("Variable '%s' was resolved but " + "'%s' is not a valid size." % + (self.size_var, requested_size)) + else: + requested_size = None + if relative_source is None or requested_size is None: + thumbnail = '' + else: + try: + kwargs = {} + for key, value in self.kwargs.items(): + kwargs[key] = value.resolve(context) + opts = dict([(k, v and v.resolve(context)) + for k, v in self.opts.items()]) + thumbnail = DjangoThumbnail(relative_source, requested_size, + opts=opts, processors=PROCESSORS, **kwargs) + except: + if DEBUG: + raise + else: + thumbnail = '' + # Return the thumbnail class, or put it on the context + if self.context_name is None: + return thumbnail + # We need to get here so we don't have old values in the context + # variable. + context[self.context_name] = thumbnail + return '' + + +def thumbnail(parser, token): + """ + Creates a thumbnail of for an ImageField. + + To just output the absolute url to the thumbnail:: + + {% thumbnail image 80x80 %} + + After the image path and dimensions, you can put any options:: + + {% thumbnail image 80x80 quality=95 crop %} + + To put the DjangoThumbnail class on the context instead of just rendering + the absolute url, finish the tag with ``as [context_var_name]``:: + + {% thumbnail image 80x80 as thumb %} + {{ thumb.width }} x {{ thumb.height }} + """ + args = token.split_contents() + tag = args[0] + # Check to see if we're setting to a context variable. + if len(args) > 4 and args[-2] == 'as': + context_name = args[-1] + args = args[:-2] + else: + context_name = None + + if len(args) < 3: + raise TemplateSyntaxError("Invalid syntax. Expected " + "'{%% %s source size [option1 option2 ...] %%}' or " + "'{%% %s source size [option1 option2 ...] as variable %%}'" % + (tag, tag)) + + # Get the source image path and requested size. + source_var = parser.compile_filter(args[1]) + # If the size argument was a correct static format, wrap it in quotes so + # that it is compiled correctly. + m = size_pat.match(args[2]) + if m: + args[2] = '"%s"' % args[2] + size_var = parser.compile_filter(args[2]) + + # Get the options. + args_list = split_args(args[3:]).items() + + # Check the options. + opts = {} + kwargs = {} # key,values here override settings and defaults + + for arg, value in args_list: + value = value and parser.compile_filter(value) + if arg in TAG_SETTINGS and value is not None: + kwargs[str(arg)] = value + continue + if arg in VALID_OPTIONS: + opts[arg] = value + else: + raise TemplateSyntaxError("'%s' tag received a bad argument: " + "'%s'" % (tag, arg)) + return ThumbnailNode(source_var, size_var, opts=opts, + context_name=context_name, **kwargs) + + +def filesize(bytes, format='auto1024'): + """ + Returns the number of bytes in either the nearest unit or a specific unit + (depending on the chosen format method). + + Acceptable formats are: + + auto1024, auto1000 + convert to the nearest unit, appending the abbreviated unit name to the + string (e.g. '2 KiB' or '2 kB'). + auto1024 is the default format. + auto1024long, auto1000long + convert to the nearest multiple of 1024 or 1000, appending the correctly + pluralized unit name to the string (e.g. '2 kibibytes' or '2 kilobytes'). + kB, MB, GB, TB, PB, EB, ZB or YB + convert to the exact unit (using multiples of 1000). + KiB, MiB, GiB, TiB, PiB, EiB, ZiB or YiB + convert to the exact unit (using multiples of 1024). + + The auto1024 and auto1000 formats return a string, appending the correct + unit to the value. All other formats return the floating point value. + + If an invalid format is specified, the bytes are returned unchanged. + """ + format_len = len(format) + # Check for valid format + if format_len in (2, 3): + if format_len == 3 and format[0] == 'K': + format = 'k%s' % format[1:] + if not format[-1] == 'B' or format[0] not in filesize_formats: + return bytes + if format_len == 3 and format[1] != 'i': + return bytes + elif format not in ('auto1024', 'auto1000', + 'auto1024long', 'auto1000long'): + return bytes + # Check for valid bytes + try: + bytes = long(bytes) + except (ValueError, TypeError): + return bytes + + # Auto multiple of 1000 or 1024 + if format.startswith('auto'): + if format[4:8] == '1000': + base = 1000 + else: + base = 1024 + logarithm = bytes and math.log(bytes, base) or 0 + index = min(int(logarithm) - 1, len(filesize_formats) - 1) + if index >= 0: + if base == 1000: + bytes = bytes and bytes / math.pow(1000, index + 1) + else: + bytes = bytes >> (10 * (index)) + bytes = bytes and bytes / 1024.0 + unit = filesize_formats[index] + else: + # Change the base to 1000 so the unit will just output 'B' not 'iB' + base = 1000 + unit = '' + if bytes >= 10 or ('%.1f' % bytes).endswith('.0'): + bytes = '%.0f' % bytes + else: + bytes = '%.1f' % bytes + if format.endswith('long'): + unit = filesize_long_formats.get(unit, '') + if base == 1024 and unit: + unit = '%sbi' % unit[:2] + unit = '%sbyte%s' % (unit, bytes != '1' and 's' or '') + else: + unit = '%s%s' % (base == 1024 and unit.upper() or unit, + base == 1024 and 'iB' or 'B') + + return '%s %s' % (bytes, unit) + + if bytes == 0: + return bytes + base = filesize_formats.index(format[0]) + 1 + # Exact multiple of 1000 + if format_len == 2: + return bytes / (1000.0 ** base) + # Exact multiple of 1024 + elif format_len == 3: + bytes = bytes >> (10 * (base - 1)) + return bytes / 1024.0 + + +register.tag(thumbnail) +register.filter(filesize) diff --git a/apps/sorl/thumbnail/tests/__init__.py b/apps/sorl/thumbnail/tests/__init__.py new file mode 100644 index 000000000..98f1cbd8f --- /dev/null +++ b/apps/sorl/thumbnail/tests/__init__.py @@ -0,0 +1,16 @@ +# For these tests to run successfully, two conditions must be met: +# 1. MEDIA_URL and MEDIA_ROOT must be set in settings +# 2. The user running the tests must have read/write access to MEDIA_ROOT + +# Unit tests: +from sorl.thumbnail.tests.classes import ThumbnailTest, DjangoThumbnailTest +from sorl.thumbnail.tests.templatetags import ThumbnailTagTest +from sorl.thumbnail.tests.fields import FieldTest, \ + ImageWithThumbnailsFieldTest, ThumbnailFieldTest +# Doc tests: +from sorl.thumbnail.tests.utils import utils_tests +from sorl.thumbnail.tests.templatetags import filesize_tests +__test__ = { + 'utils_tests': utils_tests, + 'filesize_tests': filesize_tests, +} diff --git a/apps/sorl/thumbnail/tests/base.py b/apps/sorl/thumbnail/tests/base.py new file mode 100644 index 000000000..44a2fa226 --- /dev/null +++ b/apps/sorl/thumbnail/tests/base.py @@ -0,0 +1,105 @@ +import unittest +import os +from PIL import Image +from django.conf import settings +from sorl.thumbnail.base import Thumbnail + +try: + set +except NameError: + from sets import Set as set # For Python 2.3 + + +def get_default_settings(): + from sorl.thumbnail import defaults + def_settings = {} + for key in dir(defaults): + if key == key.upper() and key not in ['WVPS', 'CONVERT']: + def_settings[key] = getattr(defaults, key) + return def_settings + + +DEFAULT_THUMBNAIL_SETTINGS = get_default_settings() +RELATIVE_PIC_NAME = "sorl-thumbnail-test_source.jpg" +PIC_NAME = os.path.join(settings.MEDIA_ROOT, RELATIVE_PIC_NAME) +THUMB_NAME = os.path.join(settings.MEDIA_ROOT, "sorl-thumbnail-test_%02d.jpg") +PIC_SIZE = (800, 600) + + +class ChangeSettings: + def __init__(self): + self.default_settings = DEFAULT_THUMBNAIL_SETTINGS.copy() + + def change(self, override=None): + if override is not None: + self.default_settings.update(override) + for setting, default in self.default_settings.items(): + settings_s = 'THUMBNAIL_%s' % setting + self_s = 'original_%s' % setting + if hasattr(settings, settings_s) and not hasattr(self, self_s): + setattr(self, self_s, getattr(settings, settings_s)) + if hasattr(settings, settings_s) or \ + default != DEFAULT_THUMBNAIL_SETTINGS[setting]: + setattr(settings, settings_s, default) + + def revert(self): + for setting in self.default_settings: + settings_s = 'THUMBNAIL_%s' % setting + self_s = 'original_%s' % setting + if hasattr(self, self_s): + setattr(settings, settings_s, getattr(self, self_s)) + delattr(self, self_s) + + +class BaseTest(unittest.TestCase): + def setUp(self): + self.images_to_delete = set() + # Create the test image + Image.new('RGB', PIC_SIZE).save(PIC_NAME, 'JPEG') + self.images_to_delete.add(PIC_NAME) + # Change settings so we know they will be constant + self.change_settings = ChangeSettings() + self.change_settings.change() + + def verify_thumbnail(self, expected_size, thumbnail=None, + expected_filename=None, expected_mode=None): + assert thumbnail is not None or expected_filename is not None, \ + 'verify_thumbnail should be passed at least a thumbnail or an' \ + 'expected filename.' + + if thumbnail is not None: + # Verify that the templatetag method returned a Thumbnail instance + self.assertTrue(isinstance(thumbnail, Thumbnail)) + thumb_name = thumbnail.dest + else: + thumb_name = expected_filename + + if isinstance(thumb_name, basestring): + # Verify that the thumbnail file exists + self.assert_(os.path.isfile(thumb_name), + 'Thumbnail file not found') + + # Remember to delete the file + self.images_to_delete.add(thumb_name) + + # If we got an expected_filename, check that it is right + if expected_filename is not None and thumbnail is not None: + self.assertEqual(thumbnail.dest, expected_filename) + + image = Image.open(thumb_name) + + # Verify the thumbnail has the expected dimensions + self.assertEqual(image.size, expected_size) + + if expected_mode is not None: + self.assertEqual(image.mode, expected_mode) + + def tearDown(self): + # Remove all the files that have been created + for image in self.images_to_delete: + try: + os.remove(image) + except: + pass + # Change settings back to original + self.change_settings.revert() diff --git a/apps/sorl/thumbnail/tests/classes.py b/apps/sorl/thumbnail/tests/classes.py new file mode 100644 index 000000000..d15dd1937 --- /dev/null +++ b/apps/sorl/thumbnail/tests/classes.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +import os +import time +from StringIO import StringIO + +from PIL import Image +from django.conf import settings + +from sorl.thumbnail.base import Thumbnail +from sorl.thumbnail.main import DjangoThumbnail, get_thumbnail_setting +from sorl.thumbnail.processors import dynamic_import, get_valid_options +from sorl.thumbnail.tests.base import BaseTest, RELATIVE_PIC_NAME, PIC_NAME,\ + THUMB_NAME, PIC_SIZE + + +class ThumbnailTest(BaseTest): + def testThumbnails(self): + # Thumbnail + thumb = Thumbnail(source=PIC_NAME, dest=THUMB_NAME % 1, + requested_size=(240, 240)) + self.verify_thumbnail((240, 180), thumb) + + # Cropped thumbnail + thumb = Thumbnail(source=PIC_NAME, dest=THUMB_NAME % 2, + requested_size=(240, 240), opts=['crop']) + self.verify_thumbnail((240, 240), thumb) + + # Thumbnail with altered JPEG quality + thumb = Thumbnail(source=PIC_NAME, dest=THUMB_NAME % 3, + requested_size=(240, 240), quality=95) + self.verify_thumbnail((240, 180), thumb) + + def testRegeneration(self): + # Create thumbnail + thumb_name = THUMB_NAME % 4 + thumb_size = (240, 240) + Thumbnail(source=PIC_NAME, dest=thumb_name, requested_size=thumb_size) + self.images_to_delete.add(thumb_name) + thumb_mtime = os.path.getmtime(thumb_name) + time.sleep(1) + + # Create another instance, shouldn't generate a new thumb + Thumbnail(source=PIC_NAME, dest=thumb_name, requested_size=thumb_size) + self.assertEqual(os.path.getmtime(thumb_name), thumb_mtime) + + # Recreate the source image, then see if a new thumb is generated + Image.new('RGB', PIC_SIZE).save(PIC_NAME, 'JPEG') + Thumbnail(source=PIC_NAME, dest=thumb_name, requested_size=thumb_size) + self.assertNotEqual(os.path.getmtime(thumb_name), thumb_mtime) + + def testFilelikeDest(self): + # Thumbnail + filelike_dest = StringIO() + thumb = Thumbnail(source=PIC_NAME, dest=filelike_dest, + requested_size=(240, 240)) + self.verify_thumbnail((240, 180), thumb) + + def testRGBA(self): + # RGBA image + rgba_pic_name = os.path.join(settings.MEDIA_ROOT, + 'sorl-thumbnail-test_rgba_source.png') + Image.new('RGBA', PIC_SIZE).save(rgba_pic_name) + self.images_to_delete.add(rgba_pic_name) + # Create thumb and verify it's still RGBA + rgba_thumb_name = os.path.join(settings.MEDIA_ROOT, + 'sorl-thumbnail-test_rgba_dest.png') + thumb = Thumbnail(source=rgba_pic_name, dest=rgba_thumb_name, + requested_size=(240, 240)) + self.verify_thumbnail((240, 180), thumb, expected_mode='RGBA') + + +class DjangoThumbnailTest(BaseTest): + def setUp(self): + super(DjangoThumbnailTest, self).setUp() + # Add another source image in a sub-directory for testing subdir and + # basedir. + self.sub_dir = os.path.join(settings.MEDIA_ROOT, 'test_thumbnail') + try: + os.mkdir(self.sub_dir) + except OSError: + pass + self.pic_subdir = os.path.join(self.sub_dir, RELATIVE_PIC_NAME) + Image.new('RGB', PIC_SIZE).save(self.pic_subdir, 'JPEG') + self.images_to_delete.add(self.pic_subdir) + + def testFilenameGeneration(self): + basename = RELATIVE_PIC_NAME.replace('.', '_') + # Basic filename + thumb = DjangoThumbnail(relative_source=RELATIVE_PIC_NAME, + requested_size=(240, 120)) + expected = os.path.join(settings.MEDIA_ROOT, basename) + expected += '_240x120_q85.jpg' + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + + # Changed quality and cropped + thumb = DjangoThumbnail(relative_source=RELATIVE_PIC_NAME, + requested_size=(240, 120), opts=['crop'], + quality=95) + expected = os.path.join(settings.MEDIA_ROOT, basename) + expected += '_240x120_crop_q95.jpg' + self.verify_thumbnail((240, 120), thumb, expected_filename=expected) + + # All options on + processors = dynamic_import(get_thumbnail_setting('PROCESSORS')) + valid_options = get_valid_options(processors) + + thumb = DjangoThumbnail(relative_source=RELATIVE_PIC_NAME, + requested_size=(240, 120), opts=valid_options) + expected = (os.path.join(settings.MEDIA_ROOT, basename) + '_240x120_' + 'autocrop_bw_crop_detail_max_sharpen_upscale_q85.jpg') + self.verify_thumbnail((240, 120), thumb, expected_filename=expected) + + # Different basedir + basedir = 'sorl-thumbnail-test-basedir' + self.change_settings.change({'BASEDIR': basedir}) + thumb = DjangoThumbnail(relative_source=self.pic_subdir, + requested_size=(240, 120)) + expected = os.path.join(basedir, self.sub_dir, basename) + expected += '_240x120_q85.jpg' + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + # Different subdir + self.change_settings.change({'BASEDIR': '', 'SUBDIR': 'subdir'}) + thumb = DjangoThumbnail(relative_source=self.pic_subdir, + requested_size=(240, 120)) + expected = os.path.join(settings.MEDIA_ROOT, + os.path.basename(self.sub_dir), 'subdir', + basename) + expected += '_240x120_q85.jpg' + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + # Different prefix + self.change_settings.change({'SUBDIR': '', 'PREFIX': 'prefix-'}) + thumb = DjangoThumbnail(relative_source=self.pic_subdir, + requested_size=(240, 120)) + expected = os.path.join(self.sub_dir, 'prefix-' + basename) + expected += '_240x120_q85.jpg' + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + + def testAlternateExtension(self): + basename = RELATIVE_PIC_NAME.replace('.', '_') + # Control JPG + thumb = DjangoThumbnail(relative_source=RELATIVE_PIC_NAME, + requested_size=(240, 120)) + expected = os.path.join(settings.MEDIA_ROOT, basename) + expected += '_240x120_q85.jpg' + expected_jpg = expected + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + # Test PNG + thumb = DjangoThumbnail(relative_source=RELATIVE_PIC_NAME, + requested_size=(240, 120), extension='png') + expected = os.path.join(settings.MEDIA_ROOT, basename) + expected += '_240x120_q85.png' + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + # Compare the file size to make sure it's not just saving as a JPG with + # a different extension. + self.assertNotEqual(os.path.getsize(expected_jpg), + os.path.getsize(expected)) + + def testUnicodeName(self): + unicode_name = 'sorl-thumbnail-ążśź_source.jpg' + unicode_path = os.path.join(settings.MEDIA_ROOT, unicode_name) + Image.new('RGB', PIC_SIZE).save(unicode_path) + self.images_to_delete.add(unicode_path) + thumb = DjangoThumbnail(relative_source=unicode_name, + requested_size=(240, 120)) + base_name = unicode_name.replace('.', '_') + expected = os.path.join(settings.MEDIA_ROOT, + base_name + '_240x120_q85.jpg') + self.verify_thumbnail((160, 120), thumb, expected_filename=expected) + + def tearDown(self): + super(DjangoThumbnailTest, self).tearDown() + subdir = os.path.join(self.sub_dir, 'subdir') + if os.path.exists(subdir): + os.rmdir(subdir) + os.rmdir(self.sub_dir) diff --git a/apps/sorl/thumbnail/tests/fields.py b/apps/sorl/thumbnail/tests/fields.py new file mode 100644 index 000000000..425f5553a --- /dev/null +++ b/apps/sorl/thumbnail/tests/fields.py @@ -0,0 +1,131 @@ +import os.path + +from django.db import models +from django.conf import settings +from django.core.files.base import ContentFile + +from sorl.thumbnail.fields import ImageWithThumbnailsField, ThumbnailField +from sorl.thumbnail.tests.base import BaseTest, RELATIVE_PIC_NAME, PIC_NAME + +thumbnail = { + 'size': (50, 50), +} +extra_thumbnails = { + 'admin': { + 'size': (30, 30), + 'options': ('crop',), + } +} +extension_thumbnail = thumbnail.copy() +extension_thumbnail['extension'] = 'png' + + +# Temporary models for field_tests +class TestThumbnailFieldModel(models.Model): + avatar = ThumbnailField(upload_to='test', size=(300, 300)) + photo = ImageWithThumbnailsField(upload_to='test', thumbnail=thumbnail, + extra_thumbnails=extra_thumbnails) + + +class TestThumbnailFieldExtensionModel(models.Model): + photo = ImageWithThumbnailsField(upload_to='test', + thumbnail=extension_thumbnail, + extra_thumbnails=extra_thumbnails) + + +class TestThumbnailFieldGenerateModel(models.Model): + photo = ImageWithThumbnailsField(upload_to='test', thumbnail=thumbnail, + extra_thumbnails=extra_thumbnails, + generate_on_save=True) + + +class FieldTest(BaseTest): + """ + Test the base field functionality. These use an ImageWithThumbnailsField + but all the functionality tested is from BaseThumbnailField. + """ + def test_extra_thumbnails(self): + model = TestThumbnailFieldModel(photo=RELATIVE_PIC_NAME) + self.assertTrue('admin' in model.photo.extra_thumbnails) + thumb = model.photo.extra_thumbnails['admin'] + tag = model.photo.extra_thumbnails_tag['admin'] + expected_filename = os.path.join(settings.MEDIA_ROOT, + 'sorl-thumbnail-test_source_jpg_30x30_crop_q85.jpg') + self.verify_thumbnail((30, 30), thumb, expected_filename) + expected_tag = '' % \ + '/'.join((settings.MEDIA_URL.rstrip('/'), + 'sorl-thumbnail-test_source_jpg_30x30_crop_q85.jpg')) + self.assertEqual(tag, expected_tag) + + def test_extension(self): + model = TestThumbnailFieldExtensionModel(photo=RELATIVE_PIC_NAME) + thumb = model.photo.thumbnail + tag = model.photo.thumbnail_tag + expected_filename = os.path.join(settings.MEDIA_ROOT, + 'sorl-thumbnail-test_source_jpg_50x50_q85.png') + self.verify_thumbnail((50, 37), thumb, expected_filename) + expected_tag = '' % \ + '/'.join((settings.MEDIA_URL.rstrip('/'), + 'sorl-thumbnail-test_source_jpg_50x50_q85.png')) + self.assertEqual(tag, expected_tag) + + def test_delete_thumbnails(self): + model = TestThumbnailFieldModel(photo=RELATIVE_PIC_NAME) + thumb_file = model.photo.thumbnail.dest + open(thumb_file, 'wb').close() + self.assert_(os.path.exists(thumb_file)) + model.photo.delete(save=False) + self.assertFalse(os.path.exists(thumb_file)) + + def test_generate_on_save(self): + main_thumb = os.path.join(settings.MEDIA_ROOT, 'test', + 'sorl-thumbnail-test_source_jpg_50x50_q85.jpg') + admin_thumb = os.path.join(settings.MEDIA_ROOT, 'test', + 'sorl-thumbnail-test_source_jpg_30x30_crop_q85.jpg') + self.images_to_delete.add(main_thumb) + self.images_to_delete.add(admin_thumb) + # Default setting is to only generate when the thumbnail is used. + model = TestThumbnailFieldModel() + source = ContentFile(open(PIC_NAME).read()) + model.photo.save(RELATIVE_PIC_NAME, source, save=False) + self.images_to_delete.add(model.photo.path) + self.assertFalse(os.path.exists(main_thumb)) + self.assertFalse(os.path.exists(admin_thumb)) + os.remove(model.photo.path) + # But it's easy to set it up the other way... + model = TestThumbnailFieldGenerateModel() + source = ContentFile(open(PIC_NAME).read()) + model.photo.save(RELATIVE_PIC_NAME, source, save=False) + self.assert_(os.path.exists(main_thumb)) + self.assert_(os.path.exists(admin_thumb)) + + +class ImageWithThumbnailsFieldTest(BaseTest): + def test_thumbnail(self): + model = TestThumbnailFieldModel(photo=RELATIVE_PIC_NAME) + thumb = model.photo.thumbnail + tag = model.photo.thumbnail_tag + base_name = RELATIVE_PIC_NAME.replace('.', '_') + expected_filename = os.path.join(settings.MEDIA_ROOT, + '%s_50x50_q85.jpg' % base_name) + self.verify_thumbnail((50, 37), thumb, expected_filename) + expected_tag = ('' % + '/'.join([settings.MEDIA_URL.rstrip('/'), + '%s_50x50_q85.jpg' % base_name])) + self.assertEqual(tag, expected_tag) + + +class ThumbnailFieldTest(BaseTest): + def test_thumbnail(self): + model = TestThumbnailFieldModel() + source = ContentFile(open(PIC_NAME).read()) + dest_name = 'sorl-thumbnail-test_dest.jpg' + model.avatar.save(dest_name, source, save=False) + expected_filename = os.path.join(model.avatar.path) + self.verify_thumbnail((300, 225), expected_filename=expected_filename) + + tag = model.avatar.thumbnail_tag + expected_tag = ('' % + '/'.join([settings.MEDIA_URL.rstrip('/'), 'test', + dest_name])) + self.assertEqual(tag, expected_tag) diff --git a/apps/sorl/thumbnail/tests/templatetags.py b/apps/sorl/thumbnail/tests/templatetags.py new file mode 100644 index 000000000..5d1a1cb17 --- /dev/null +++ b/apps/sorl/thumbnail/tests/templatetags.py @@ -0,0 +1,312 @@ +import os +from django.conf import settings +from django.template import Template, Context, TemplateSyntaxError +from sorl.thumbnail.tests.classes import BaseTest, RELATIVE_PIC_NAME + + +class ThumbnailTagTest(BaseTest): + def render_template(self, source): + context = Context({ + 'source': RELATIVE_PIC_NAME, + 'invalid_source': 'not%s' % RELATIVE_PIC_NAME, + 'size': (90, 100), + 'invalid_size': (90, 'fish'), + 'strsize': '80x90', + 'invalid_strsize': ('1notasize2'), + 'invalid_q': 'notanumber'}) + source = '{% load thumbnail %}' + source + return Template(source).render(context) + + def testTagInvalid(self): + # No args, or wrong number of args + src = '{% thumbnail %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + src = '{% thumbnail source %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + src = '{% thumbnail source 80x80 as variable crop %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + # Invalid option + src = '{% thumbnail source 240x200 invalid %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + # Old comma separated options format can only have an = for quality + src = '{% thumbnail source 80x80 crop=1,quality=1 %}' + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + # Invalid quality + src_invalid = '{% thumbnail source 240x200 quality=invalid_q %}' + src_missing = '{% thumbnail source 240x200 quality=missing_q %}' + # ...with THUMBNAIL_DEBUG = False + self.assertEqual(self.render_template(src_invalid), '') + self.assertEqual(self.render_template(src_missing), '') + # ...and with THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, + src_invalid) + self.assertRaises(TemplateSyntaxError, self.render_template, + src_missing) + + # Invalid source + src = '{% thumbnail invalid_source 80x80 %}' + src_on_context = '{% thumbnail invalid_source 80x80 as thumb %}' + # ...with THUMBNAIL_DEBUG = False + self.change_settings.change({'DEBUG': False}) + self.assertEqual(self.render_template(src), '') + # ...and with THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, src) + self.assertRaises(TemplateSyntaxError, self.render_template, + src_on_context) + + # Non-existant source + src = '{% thumbnail non_existant_source 80x80 %}' + src_on_context = '{% thumbnail non_existant_source 80x80 as thumb %}' + # ...with THUMBNAIL_DEBUG = False + self.change_settings.change({'DEBUG': False}) + self.assertEqual(self.render_template(src), '') + # ...and with THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + # Invalid size as a tuple: + src = '{% thumbnail source invalid_size %}' + # ...with THUMBNAIL_DEBUG = False + self.change_settings.change({'DEBUG': False}) + self.assertEqual(self.render_template(src), '') + # ...and THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, src) + # Invalid size as a string: + src = '{% thumbnail source invalid_strsize %}' + # ...with THUMBNAIL_DEBUG = False + self.change_settings.change({'DEBUG': False}) + self.assertEqual(self.render_template(src), '') + # ...and THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + # Non-existant size + src = '{% thumbnail source non_existant_size %}' + # ...with THUMBNAIL_DEBUG = False + self.change_settings.change({'DEBUG': False}) + self.assertEqual(self.render_template(src), '') + # ...and THUMBNAIL_DEBUG = True + self.change_settings.change({'DEBUG': True}) + self.assertRaises(TemplateSyntaxError, self.render_template, src) + + def testTag(self): + expected_base = RELATIVE_PIC_NAME.replace('.', '_') + # Set DEBUG = True to make it easier to trace any failures + self.change_settings.change({'DEBUG': True}) + + # Basic + output = self.render_template('src="' + '{% thumbnail source 240x240 %}"') + expected = '%s_240x240_q85.jpg' % expected_base + expected_fn = os.path.join(settings.MEDIA_ROOT, expected) + self.verify_thumbnail((240, 180), expected_filename=expected_fn) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="%s"' % expected_url) + + # Size from context variable + # as a tuple: + output = self.render_template('src="' + '{% thumbnail source size %}"') + expected = '%s_90x100_q85.jpg' % expected_base + expected_fn = os.path.join(settings.MEDIA_ROOT, expected) + self.verify_thumbnail((90, 67), expected_filename=expected_fn) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="%s"' % expected_url) + # as a string: + output = self.render_template('src="' + '{% thumbnail source strsize %}"') + expected = '%s_80x90_q85.jpg' % expected_base + expected_fn = os.path.join(settings.MEDIA_ROOT, expected) + self.verify_thumbnail((80, 60), expected_filename=expected_fn) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="%s"' % expected_url) + + # On context + output = self.render_template('height:' + '{% thumbnail source 240x240 as thumb %}{{ thumb.height }}') + self.assertEqual(output, 'height:180') + + # With options and quality + output = self.render_template('src="' + '{% thumbnail source 240x240 sharpen crop quality=95 %}"') + # Note that the opts are sorted to ensure a consistent filename. + expected = '%s_240x240_crop_sharpen_q95.jpg' % expected_base + expected_fn = os.path.join(settings.MEDIA_ROOT, expected) + self.verify_thumbnail((240, 240), expected_filename=expected_fn) + expected_url = ''.join((settings.MEDIA_URL, expected)) + self.assertEqual(output, 'src="%s"' % expected_url) + + # With option and quality on context (also using its unicode method to + # display the url) + output = self.render_template( + '{% thumbnail source 240x240 sharpen crop quality=95 as thumb %}' + 'width:{{ thumb.width }}, url:{{ thumb }}') + self.assertEqual(output, 'width:240, url:%s' % expected_url) + + # Old comma separated format for options is still supported. + output = self.render_template( + '{% thumbnail source 240x240 sharpen,crop,quality=95 as thumb %}' + 'width:{{ thumb.width }}, url:{{ thumb }}') + self.assertEqual(output, 'width:240, url:%s' % expected_url) + +filesize_tests = r""" +>>> from sorl.thumbnail.templatetags.thumbnail import filesize + +>>> filesize('abc') +'abc' +>>> filesize(100, 'invalid') +100 + +>>> bytes = 20 +>>> filesize(bytes) +'20 B' +>>> filesize(bytes, 'auto1000') +'20 B' + +>>> bytes = 1001 +>>> filesize(bytes) +'1001 B' +>>> filesize(bytes, 'auto1000') +'1 kB' + +>>> bytes = 10100 +>>> filesize(bytes) +'9.9 KiB' + +# Note that the decimal place is only used if < 10 +>>> filesize(bytes, 'auto1000') +'10 kB' + +>>> bytes = 190000000 +>>> filesize(bytes) +'181 MiB' +>>> filesize(bytes, 'auto1000') +'190 MB' + +# 'auto*long' methods use pluralisation: +>>> filesize(1, 'auto1024long') +'1 byte' +>>> filesize(1, 'auto1000long') +'1 byte' +>>> filesize(2, 'auto1024long') +'2 bytes' +>>> filesize(0, 'auto1000long') +'0 bytes' + +# Test all 'auto*long' output: +>>> for i in range(1,10): +... print '%s, %s' % (filesize(1024**i, 'auto1024long'), +... filesize(1000**i, 'auto1000long')) +1 kibibyte, 1 kilobyte +1 mebibyte, 1 megabyte +1 gibibyte, 1 gigabyte +1 tebibyte, 1 terabyte +1 pebibyte, 1 petabyte +1 exbibyte, 1 exabyte +1 zebibyte, 1 zettabyte +1 yobibyte, 1 yottabyte +1024 yobibytes, 1000 yottabytes + +# Test all fixed outputs (eg 'kB' or 'MiB') +>>> from sorl.thumbnail.templatetags.thumbnail import filesize_formats,\ +... filesize_long_formats +>>> for f in filesize_formats: +... print '%s (%siB, %sB):' % (filesize_long_formats[f], f.upper(), f) +... for i in range(0, 10): +... print ' %s, %s' % (filesize(1024**i, '%siB' % f.upper()), +... filesize(1000**i, '%sB' % f)) +kilo (KiB, kB): + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 + 1.09951162778e+12, 1e+12 + 1.12589990684e+15, 1e+15 + 1.15292150461e+18, 1e+18 + 1.18059162072e+21, 1e+21 + 1.20892581961e+24, 1e+24 +mega (MiB, MB): + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 + 1.09951162778e+12, 1e+12 + 1.12589990684e+15, 1e+15 + 1.15292150461e+18, 1e+18 + 1.18059162072e+21, 1e+21 +giga (GiB, GB): + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 + 1.09951162778e+12, 1e+12 + 1.12589990684e+15, 1e+15 + 1.15292150461e+18, 1e+18 +tera (TiB, TB): + 0.0, 1e-12 + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 + 1.09951162778e+12, 1e+12 + 1.12589990684e+15, 1e+15 +peta (PiB, PB): + 0.0, 1e-15 + 0.0, 1e-12 + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 + 1.09951162778e+12, 1e+12 +exa (EiB, EB): + 0.0, 1e-18 + 0.0, 1e-15 + 0.0, 1e-12 + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 + 1073741824.0, 1000000000.0 +zetta (ZiB, ZB): + 0.0, 1e-21 + 0.0, 1e-18 + 0.0, 1e-15 + 0.0, 1e-12 + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 + 1048576.0, 1000000.0 +yotta (YiB, YB): + 0.0, 1e-24 + 0.0, 1e-21 + 0.0, 1e-18 + 0.0, 1e-15 + 0.0, 1e-12 + 0.0, 1e-09 + 0.0, 1e-06 + 0.0009765625, 0.001 + 1.0, 1.0 + 1024.0, 1000.0 +""" diff --git a/apps/sorl/thumbnail/tests/utils.py b/apps/sorl/thumbnail/tests/utils.py new file mode 100644 index 000000000..3a20cbbc9 --- /dev/null +++ b/apps/sorl/thumbnail/tests/utils.py @@ -0,0 +1,149 @@ +from django.conf import settings +from sorl.thumbnail.utils import * + +try: + set +except NameError: + from sets import Set as set # For Python 2.3 + +MEDIA_ROOT_LENGTH = len(os.path.normpath(settings.MEDIA_ROOT)) + +utils_tests = r""" +>>> from sorl.thumbnail.tests.utils import * +>>> from sorl.thumbnail.tests.base import ChangeSettings +>>> from django.conf import settings + +>>> change_settings = ChangeSettings() +>>> change_settings.change() + +>>> media_root = settings.MEDIA_ROOT.rstrip('/') + +#============================================================================== +# Set up test images +#============================================================================== + +>>> make_image('test-thumbnail-utils/subdir/test_jpg_110x110_q85.jpg') +>>> make_image('test-thumbnail-utils/test_jpg_80x80_q85.jpg') +>>> make_image('test-thumbnail-utils/test_jpg_80x80_q95.jpg') +>>> make_image('test-thumbnail-utils/another_test_jpg_80x80_q85.jpg') +>>> make_image('test-thumbnail-utils/test_with_opts_jpg_80x80_crop_bw_q85.jpg') +>>> make_image('test-thumbnail-basedir/test-thumbnail-utils/test_jpg_100x100_' +... 'q85.jpg') +>>> make_image('test-thumbnail-utils/prefix-test_jpg_120x120_q85.jpg') + +#============================================================================== +# all_thumbnails() +#============================================================================== + +# Find all thumbs +>>> thumb_dir = os.path.join(settings.MEDIA_ROOT, 'test-thumbnail-utils') +>>> thumbs = all_thumbnails(thumb_dir) +>>> k = thumbs.keys() +>>> k.sort() +>>> [consistent_slash(path) for path in k] +['another_test.jpg', 'prefix-test.jpg', 'subdir/test.jpg', 'test.jpg', + 'test_with_opts.jpg'] + +# Find all thumbs, no recurse +>>> thumbs = all_thumbnails(thumb_dir, recursive=False) +>>> k = thumbs.keys() +>>> k.sort() +>>> k +['another_test.jpg', 'prefix-test.jpg', 'test.jpg', 'test_with_opts.jpg'] + +#============================================================================== +# thumbnails_for_file() +#============================================================================== + +>>> output = [] +>>> for thumb in thumbs['test.jpg']: +... thumb['rel_fn'] = strip_media_root(thumb['filename']) +... output.append('%(x)sx%(y)s %(quality)s %(rel_fn)s' % thumb) +>>> output.sort() +>>> output +['80x80 85 test-thumbnail-utils/test_jpg_80x80_q85.jpg', + '80x80 95 test-thumbnail-utils/test_jpg_80x80_q95.jpg'] + +# Thumbnails for file +>>> output = [] +>>> for thumb in thumbnails_for_file('test-thumbnail-utils/test.jpg'): +... output.append(strip_media_root(thumb['filename'])) +>>> output.sort() +>>> output +['test-thumbnail-utils/test_jpg_80x80_q85.jpg', + 'test-thumbnail-utils/test_jpg_80x80_q95.jpg'] + +# Thumbnails for file - shouldn't choke on non-existant file +>>> thumbnails_for_file('test-thumbnail-utils/non-existant.jpg') +[] + +# Thumbnails for file, with basedir setting +>>> change_settings.change({'BASEDIR': 'test-thumbnail-basedir'}) +>>> for thumb in thumbnails_for_file('test-thumbnail-utils/test.jpg'): +... print strip_media_root(thumb['filename']) +test-thumbnail-basedir/test-thumbnail-utils/test_jpg_100x100_q85.jpg + +# Thumbnails for file, with subdir setting +>>> change_settings.change({'SUBDIR': 'subdir', 'BASEDIR': ''}) +>>> for thumb in thumbnails_for_file('test-thumbnail-utils/test.jpg'): +... print strip_media_root(thumb['filename']) +test-thumbnail-utils/subdir/test_jpg_110x110_q85.jpg + +# Thumbnails for file, with prefix setting +>>> change_settings.change({'PREFIX': 'prefix-', 'SUBDIR': ''}) +>>> for thumb in thumbnails_for_file('test-thumbnail-utils/test.jpg'): +... print strip_media_root(thumb['filename']) +test-thumbnail-utils/prefix-test_jpg_120x120_q85.jpg + +#============================================================================== +# Clean up images / directories +#============================================================================== + +>>> clean_up() +""" + +images_to_delete = set() +dirs_to_delete = [] + + +def make_image(relative_image): + absolute_image = os.path.join(settings.MEDIA_ROOT, relative_image) + make_dirs(os.path.dirname(relative_image)) + open(absolute_image, 'w').close() + images_to_delete.add(absolute_image) + + +def make_dirs(relative_path): + if not relative_path: + return + absolute_path = os.path.join(settings.MEDIA_ROOT, relative_path) + if os.path.isdir(absolute_path): + return + if absolute_path not in dirs_to_delete: + dirs_to_delete.append(absolute_path) + make_dirs(os.path.dirname(relative_path)) + os.mkdir(absolute_path) + + +def clean_up(): + for image in images_to_delete: + os.remove(image) + for path in dirs_to_delete: + os.rmdir(path) + + +def strip_media_root(path): + path = os.path.normpath(path) + # chop off the MEDIA_ROOT and strip any leading os.sep + path = path[MEDIA_ROOT_LENGTH:].lstrip(os.sep) + return consistent_slash(path) + + +def consistent_slash(path): + """ + Ensure we're always testing against the '/' os separator (otherwise tests + fail against Windows). + """ + if os.sep != '/': + path = path.replace(os.sep, '/') + return path diff --git a/apps/sorl/thumbnail/utils.py b/apps/sorl/thumbnail/utils.py new file mode 100644 index 000000000..18b18b0bc --- /dev/null +++ b/apps/sorl/thumbnail/utils.py @@ -0,0 +1,170 @@ +import math +import os +import re + + +re_thumbnail_file = re.compile(r'(?P.+)_(?P\d+)x(?P\d+)' + r'(?:_(?P\w+))?_q(?P\d+)' + r'(?:.[^.]+)?$') +re_new_args = re.compile('(? 1 and split_arg[1] or None + args_dict[split_arg[0]] = value + return args_dict + + +def image_entropy(im): + """ + Calculate the entropy of an image. Used for "smart cropping". + """ + hist = im.histogram() + hist_size = float(sum(hist)) + hist = [h / hist_size for h in hist] + return -sum([p * math.log(p, 2) for p in hist if p != 0]) diff --git a/apps/sponsors/models.py b/apps/sponsors/models.py index 1df990f68..7b52b76f9 100644 --- a/apps/sponsors/models.py +++ b/apps/sponsors/models.py @@ -2,13 +2,21 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ from django.template.loader import render_to_string +from sorl.thumbnail.fields import ImageWithThumbnailsField from sponsors.fields import JSONField class Sponsor(models.Model): name = models.CharField(_('name'), max_length=120) _description = models.CharField(_('description'), blank=True, max_length=255) - logo = models.ImageField(_('logo'), upload_to='sponsors/sponsor/logo') + logo = ImageWithThumbnailsField( + _('logo'), + upload_to='sponsors/sponsor/logo', + thumbnail={ + 'size': (150, 75), + 'extension': 'png', + 'options': ['upscale', 'pad', 'detail'], + }) url = models.URLField(_('url'), blank=True, verify_exists=False) def __unicode__(self): diff --git a/apps/sponsors/processors.py b/apps/sponsors/processors.py new file mode 100644 index 000000000..d954bf622 --- /dev/null +++ b/apps/sponsors/processors.py @@ -0,0 +1,13 @@ +from PIL import Image, ImageFilter, ImageChops + + +def add_padding(image, requested_size, opts): + if 'pad' in opts: + padded_image = Image.new('RGBA', requested_size, '#fff') + width, height = image.size + requested_width, requested_height = requested_size + padded_image.paste(image, (0, requested_height - height / 2)) + return padded_image + return image + +add_padding.valid_options = ('pad',) diff --git a/apps/sponsors/static/sponsors/css/footer_admin.css b/apps/sponsors/static/sponsors/css/footer_admin.css index ba56771d8..2f0887bba 100644 --- a/apps/sponsors/static/sponsors/css/footer_admin.css +++ b/apps/sponsors/static/sponsors/css/footer_admin.css @@ -6,7 +6,7 @@ .sponsors .sponsors-sponsor-group { float: left; - width: 200px; + width: 180px; border: 1px solid #CCC; margin: 2px 2px 0 0; } @@ -25,7 +25,7 @@ margin: -2px -2px -2px -4px; padding: 0; height: 15px; - width: 180px; + width: 160px; } .sponsors .sponsors-remove-sponsor-group { @@ -59,8 +59,9 @@ } .sponsors-sponsor { - margin: 0 0 2px 0; - padding: 2px; + margin: 0 30px 2px 0; + width: 150px; + height: 75px; border: 1px solid #CCC; background-color: #EEE; cursor: default; diff --git a/apps/sponsors/static/sponsors/js/footer_admin.js b/apps/sponsors/static/sponsors/js/footer_admin.js index 2f2cd93da..4cb5eb68c 100644 --- a/apps/sponsors/static/sponsors/js/footer_admin.js +++ b/apps/sponsors/static/sponsors/js/footer_admin.js @@ -80,7 +80,7 @@ for (var i = 0; i < sponsors.length; i++) { - $('
  • ' + sponsors[i].name + '
  • ') + $('
  • ' + sponsors[i].name + '
  • ') .data('obj_id', sponsors[i].id) .appendTo(groupList); } @@ -115,7 +115,7 @@ }); for (i = 0; i < settings.sponsors.length; i++) { - $('
  • ' + settings.sponsors[i].name + '
  • ') + $('
  • ' + settings.sponsors[i].name + '
  • ') .data('obj_id', settings.sponsors[i].id) .appendTo(unusedList); } diff --git a/apps/sponsors/templates/sponsors/page.html b/apps/sponsors/templates/sponsors/page.html index 831fdb746..96beb36c7 100644 --- a/apps/sponsors/templates/sponsors/page.html +++ b/apps/sponsors/templates/sponsors/page.html @@ -1,9 +1,9 @@
    {% for column in sponsors %} -
    -

    {{ column.name }}

    +
    +

    {{ column.name|default:" " }}

    {% for sponsor in column.sponsors %} -
    {% if sponsor.url %}{% endif %}{% if sponsor.url %}{% endif %}
    +
    {% if sponsor.url %}{% endif %}{% if sponsor.url %}{% endif %}
    {% endfor %}
    {% endfor %} diff --git a/apps/sponsors/widgets.py b/apps/sponsors/widgets.py index 72aaf0a83..ed06ba641 100644 --- a/apps/sponsors/widgets.py +++ b/apps/sponsors/widgets.py @@ -19,8 +19,8 @@ class SponsorPageWidget(forms.Textarea): def render(self, name, value, attrs=None): output = [super(SponsorPageWidget, self).render(name, value, attrs)] - sponsors = [(unicode(obj), obj.pk) for obj in models.Sponsor.objects.all()] - sponsors_js = ', '.join('{name: "%s", id: %d}' % sponsor for sponsor in sponsors) + sponsors = [(unicode(obj), obj.pk, obj.logo.thumbnail) for obj in models.Sponsor.objects.all()] + sponsors_js = ', '.join('{name: "%s", id: %d, image: "%s"}' % sponsor for sponsor in sponsors) output.append(u'