Dodanie sorl.thumbnail i generowanie miniaturek logo na potrzeby aplikacji sponsors.
authorMarek Stępniowski <marek@stepniowski.com>
Thu, 17 Dec 2009 15:42:07 +0000 (16:42 +0100)
committerMarek Stępniowski <marek@stepniowski.com>
Thu, 17 Dec 2009 15:42:07 +0000 (16:42 +0100)
29 files changed:
apps/sorl/__init__.py [new file with mode: 0755]
apps/sorl/thumbnail/__init__.py [new file with mode: 0644]
apps/sorl/thumbnail/base.py [new file with mode: 0755]
apps/sorl/thumbnail/defaults.py [new file with mode: 0644]
apps/sorl/thumbnail/fields.py [new file with mode: 0644]
apps/sorl/thumbnail/main.py [new file with mode: 0644]
apps/sorl/thumbnail/management/__init__.py [new file with mode: 0644]
apps/sorl/thumbnail/management/commands/__init__.py [new file with mode: 0644]
apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py [new file with mode: 0644]
apps/sorl/thumbnail/models.py [new file with mode: 0644]
apps/sorl/thumbnail/processors.py [new file with mode: 0644]
apps/sorl/thumbnail/templatetags/__init__.py [new file with mode: 0644]
apps/sorl/thumbnail/templatetags/thumbnail.py [new file with mode: 0755]
apps/sorl/thumbnail/tests/__init__.py [new file with mode: 0644]
apps/sorl/thumbnail/tests/base.py [new file with mode: 0644]
apps/sorl/thumbnail/tests/classes.py [new file with mode: 0644]
apps/sorl/thumbnail/tests/fields.py [new file with mode: 0644]
apps/sorl/thumbnail/tests/templatetags.py [new file with mode: 0644]
apps/sorl/thumbnail/tests/utils.py [new file with mode: 0644]
apps/sorl/thumbnail/utils.py [new file with mode: 0644]
apps/sponsors/models.py
apps/sponsors/processors.py [new file with mode: 0644]
apps/sponsors/static/sponsors/css/footer_admin.css
apps/sponsors/static/sponsors/js/footer_admin.js
apps/sponsors/templates/sponsors/page.html
apps/sponsors/widgets.py
wolnelektury/settings.py
wolnelektury/static/sponsors/css/footer_admin.css
wolnelektury/static/sponsors/js/footer_admin.js

diff --git a/apps/sorl/__init__.py b/apps/sorl/__init__.py
new file mode 100755 (executable)
index 0000000..e69de29
diff --git a/apps/sorl/thumbnail/__init__.py b/apps/sorl/thumbnail/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/sorl/thumbnail/base.py b/apps/sorl/thumbnail/base.py
new file mode 100755 (executable)
index 0000000..24f4d97
--- /dev/null
@@ -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 (file)
index 0000000..b4ae142
--- /dev/null
@@ -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 (file)
index 0000000..1b52743
--- /dev/null
@@ -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 = '<img src="%(src)s" width="%(width)s" height="%(height)s" alt="" />'
+
+
+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 (file)
index 0000000..a59b64f
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/apps/sorl/thumbnail/management/commands/__init__.py b/apps/sorl/thumbnail/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py b/apps/sorl/thumbnail/management/commands/thumbnail_cleanup.py
new file mode 100644 (file)
index 0000000..690c42c
--- /dev/null
@@ -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 (file)
index 0000000..ec325fd
--- /dev/null
@@ -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 (file)
index 0000000..a6c1741
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/apps/sorl/thumbnail/templatetags/thumbnail.py b/apps/sorl/thumbnail/templatetags/thumbnail.py
new file mode 100755 (executable)
index 0000000..e7c2177
--- /dev/null
@@ -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 (file)
index 0000000..98f1cbd
--- /dev/null
@@ -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 (file)
index 0000000..44a2fa2
--- /dev/null
@@ -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 (file)
index 0000000..d15dd19
--- /dev/null
@@ -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 (file)
index 0000000..425f555
--- /dev/null
@@ -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 = '<img src="%s" width="30" height="30" alt="" />' % \
+            '/'.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 = '<img src="%s" width="50" height="37" alt="" />' % \
+            '/'.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 = ('<img src="%s" width="50" height="37" alt="" />' %
+                        '/'.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 = ('<img src="%s" width="300" height="225" alt="" />' %
+                        '/'.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 (file)
index 0000000..5d1a1cb
--- /dev/null
@@ -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 (file)
index 0000000..3a20cbb
--- /dev/null
@@ -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 (file)
index 0000000..18b18b0
--- /dev/null
@@ -0,0 +1,170 @@
+import math
+import os
+import re
+
+
+re_thumbnail_file = re.compile(r'(?P<source_filename>.+)_(?P<x>\d+)x(?P<y>\d+)'
+                               r'(?:_(?P<options>\w+))?_q(?P<quality>\d+)'
+                               r'(?:.[^.]+)?$')
+re_new_args = re.compile('(?<!quality)=')
+
+
+def all_thumbnails(path, recursive=True, prefix=None, subdir=None):
+    """
+    Return a dictionary referencing all files which match the thumbnail format.
+
+    Each key is a source image filename, relative to path.
+    Each value is a list of dictionaries as explained in `thumbnails_for_file`.
+    """
+    # Fall back to using thumbnail settings. These are local imports so that
+    # there is no requirement of Django to use the utils module.
+    if prefix is None:
+        from sorl.thumbnail.main import get_thumbnail_setting
+        prefix = get_thumbnail_setting('PREFIX')
+    if subdir is None:
+        from sorl.thumbnail.main import get_thumbnail_setting
+        subdir = get_thumbnail_setting('SUBDIR')
+    thumbnail_files = {}
+    if not path.endswith('/'):
+        path = '%s/' % path
+    len_path = len(path)
+    if recursive:
+        all = os.walk(path)
+    else:
+        files = []
+        for file in os.listdir(path):
+            if os.path.isfile(os.path.join(path, file)):
+                files.append(file)
+        all = [(path, [], files)]
+    for dir_, subdirs, files in all:
+        rel_dir = dir_[len_path:]
+        for file in files:
+            thumb = re_thumbnail_file.match(file)
+            if not thumb:
+                continue
+            d = thumb.groupdict()
+            source_filename = d.pop('source_filename')
+            if prefix:
+                source_path, source_filename = os.path.split(source_filename)
+                if not source_filename.startswith(prefix):
+                    continue
+                source_filename = os.path.join(source_path,
+                    source_filename[len(prefix):])
+            d['options'] = d['options'] and d['options'].split('_') or []
+            if subdir and rel_dir.endswith(subdir):
+                rel_dir = rel_dir[:-len(subdir)]
+            # Corner-case bug: if the filename didn't have an extension but did
+            # have an underscore, the last underscore will get converted to a
+            # '.'.
+            m = re.match(r'(.*)_(.*)', source_filename)
+            if m:
+                source_filename = '%s.%s' % m.groups()
+            filename = os.path.join(rel_dir, source_filename)
+            thumbnail_file = thumbnail_files.setdefault(filename, [])
+            d['filename'] = os.path.join(dir_, file)
+            thumbnail_file.append(d)
+    return thumbnail_files
+
+
+def thumbnails_for_file(relative_source_path, root=None, basedir=None,
+                        subdir=None, prefix=None):
+    """
+    Return a list of dictionaries, one for each thumbnail belonging to the
+    source image.
+
+    The following list explains each key of the dictionary:
+
+      `filename`  -- absolute thumbnail path
+      `x` and `y` -- the size of the thumbnail
+      `options`   -- list of options for this thumbnail
+      `quality`   -- quality setting for this thumbnail
+    """
+    # Fall back to using thumbnail settings. These are local imports so that
+    # there is no requirement of Django to use the utils module.
+    if root is None:
+        from django.conf import settings
+        root = settings.MEDIA_ROOT
+    if prefix is None:
+        from sorl.thumbnail.main import get_thumbnail_setting
+        prefix = get_thumbnail_setting('PREFIX')
+    if subdir is None:
+        from sorl.thumbnail.main import get_thumbnail_setting
+        subdir = get_thumbnail_setting('SUBDIR')
+    if basedir is None:
+        from sorl.thumbnail.main import get_thumbnail_setting
+        basedir = get_thumbnail_setting('BASEDIR')
+    source_dir, filename = os.path.split(relative_source_path)
+    thumbs_path = os.path.join(root, basedir, source_dir, subdir)
+    if not os.path.isdir(thumbs_path):
+        return []
+    files = all_thumbnails(thumbs_path, recursive=False, prefix=prefix,
+                           subdir='')
+    return files.get(filename, [])
+
+
+def delete_thumbnails(relative_source_path, root=None, basedir=None,
+                      subdir=None, prefix=None):
+    """
+    Delete all thumbnails for a source image.
+    """
+    thumbs = thumbnails_for_file(relative_source_path, root, basedir, subdir,
+                                 prefix)
+    return _delete_using_thumbs_list(thumbs)
+
+
+def _delete_using_thumbs_list(thumbs):
+    deleted = 0
+    for thumb_dict in thumbs:
+        filename = thumb_dict['filename']
+        try:
+            os.remove(filename)
+        except:
+            pass
+        else:
+            deleted += 1
+    return deleted
+
+
+def delete_all_thumbnails(path, recursive=True):
+    """
+    Delete all files within a path which match the thumbnails pattern.
+
+    By default, matching files from all sub-directories are also removed. To
+    only remove from the path directory, set recursive=False.
+    """
+    total = 0
+    for thumbs in all_thumbnails(path, recursive=recursive).values():
+        total += _delete_using_thumbs_list(thumbs)
+    return total
+
+
+def split_args(args):
+    """
+    Split a list of argument strings into a dictionary where each key is an
+    argument name.
+
+    An argument looks like ``crop``, ``crop="some option"`` or ``crop=my_var``.
+    Arguments which provide no value get a value of ``None``.
+    """
+    if not args:
+        return {}
+    # Handle the old comma separated argument format.
+    if len(args) == 1 and not re_new_args.search(args[0]):
+        args = args[0].split(',')
+    # Separate out the key and value for each argument.
+    args_dict = {}
+    for arg in args:
+        split_arg = arg.split('=', 1)
+        value = len(split_arg) > 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])
index 1df990f..7b52b76 100644 (file)
@@ -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 (file)
index 0000000..d954bf6
--- /dev/null
@@ -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',)
index ba56771..2f0887b 100644 (file)
@@ -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;
index 2f2cd93..4cb5eb6 100644 (file)
@@ -80,7 +80,7 @@
       
       
       for (var i = 0; i < sponsors.length; i++) {
-        $('<li class="sponsors-sponsor">' + sponsors[i].name + '</li>')
+        $('<li class="sponsors-sponsor"><img src="' + sponsors[i].image + '" alt="' + sponsors[i].name + '"/></li>')
           .data('obj_id', sponsors[i].id)
           .appendTo(groupList);
       }
     });
     
     for (i = 0; i < settings.sponsors.length; i++) {
-      $('<li class="sponsors-sponsor">' + settings.sponsors[i].name + '</li>')
+      $('<li class="sponsors-sponsor"><img src="' + settings.sponsors[i].image + '" alt="' + settings.sponsors[i].name + '"/></li>')
         .data('obj_id', settings.sponsors[i].id)
         .appendTo(unusedList);
     }
index 831fdb7..96beb36 100644 (file)
@@ -1,9 +1,9 @@
 <div class="sponsors-sponsor-page">
 {% for column in sponsors %}
-       <div class="sponsors-sponsor-column" style="width: {{ column_width }}px">
-               <p class="sponsors-sponsor-column-name">{{ column.name }}</p>
+       <div class="sponsors-sponsor-column" style="width: 150px">
+               <p class="sponsors-sponsor-column-name">{{ column.name|default:"&nbsp;" }}</p>
                {% for sponsor in column.sponsors %}
-                       <div class="sponsors-sponsor">{% if sponsor.url %}<a style="sponsors-sponsor-link" href="{{ sponsor.url }}" >{% endif %}<img class="sponsors-sponsor-logo" src="{{ sponsor.logo.url }}" alt="{{ sponsor.description }}"/>{% if sponsor.url %}</a>{% endif %}</div>
+                       <div class="sponsors-sponsor">{% if sponsor.url %}<a style="sponsors-sponsor-link" href="{{ sponsor.url }}" >{% endif %}<img class="sponsors-sponsor-logo" src="{{ sponsor.logo.thumbnail }}" alt="{{ sponsor.description }}"/>{% if sponsor.url %}</a>{% endif %}</div>
                {% endfor %}
        </div>
 {% endfor %}
index 72aaf0a..ed06ba6 100644 (file)
@@ -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'<script type="text/javascript">addEvent(window, "load", function(e) {')
         # TODO: "id_" is hard-coded here. This should instead use the correct
         # API to determine the ID dynamically.
index fcecc1a..98fd963 100644 (file)
@@ -98,6 +98,7 @@ INSTALLED_APPS = [
     
     # external
     'south',
+    'sorl.thumbnail',
     'sponsors',
     'newtagging',
     'pagination',
@@ -139,6 +140,18 @@ COMPRESS_JS = {
 
 COMPRESS_CSS_FILTERS = None
 
+THUMBNAIL_QUALITY = 95
+THUMBNAIL_EXTENSION = 'png'
+
+THUMBNAIL_PROCESSORS = (
+    # Default processors
+    'sorl.thumbnail.processors.colorspace',
+    'sorl.thumbnail.processors.autocrop',
+    'sorl.thumbnail.processors.scale_and_crop',
+    'sorl.thumbnail.processors.filters',
+    # Custom processors
+    'sponsors.processors.add_padding',
+)
 
 # Load localsettings, if they exist
 try:
index ba56771..2f0887b 100644 (file)
@@ -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;
index 2f2cd93..4cb5eb6 100644 (file)
@@ -80,7 +80,7 @@
       
       
       for (var i = 0; i < sponsors.length; i++) {
-        $('<li class="sponsors-sponsor">' + sponsors[i].name + '</li>')
+        $('<li class="sponsors-sponsor"><img src="' + sponsors[i].image + '" alt="' + sponsors[i].name + '"/></li>')
           .data('obj_id', sponsors[i].id)
           .appendTo(groupList);
       }
     });
     
     for (i = 0; i < settings.sponsors.length; i++) {
-      $('<li class="sponsors-sponsor">' + settings.sponsors[i].name + '</li>')
+      $('<li class="sponsors-sponsor"><img src="' + settings.sponsors[i].image + '" alt="' + settings.sponsors[i].name + '"/></li>')
         .data('obj_id', settings.sponsors[i].id)
         .appendTo(unusedList);
     }