diff --git a/wagtail/wagtailadmin/static/wagtailadmin/css/panels/rich-text.less b/wagtail/wagtailadmin/static/wagtailadmin/css/panels/rich-text.less index 22b431edf..4c2c5f7be 100644 --- a/wagtail/wagtailadmin/static/wagtailadmin/css/panels/rich-text.less +++ b/wagtail/wagtailadmin/static/wagtailadmin/css/panels/rich-text.less @@ -105,7 +105,7 @@ } /* - These styles correspond to the image formats defined in verdantimages/formats.py, + These styles correspond to the image formats defined in wagtailimages/formats.py, so that images displayed in the rich text field receive more or less the same styling that they would receive on the site front-end. TODO: when we implement a mechanism to configure the image format list on a diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_css.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_css.html index c12d8d4b3..3e4ba4e32 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_css.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_css.html @@ -5,8 +5,8 @@ {% endcomment %} {% comment %} - TODO: have a mechanism to specify this include within the verdantimages app - - ideally wagtailadmin shouldn't have to know anything at all about verdantimages + TODO: have a mechanism for sub-apps to specify their own declarations - + ideally wagtailadmin shouldn't have to know anything at all about wagtailimages and friends {% endcomment %} {% compress css %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html index 57268720a..cd4752e26 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/_editor_js.html @@ -13,19 +13,19 @@ - + + {% comment %} - TODO: have a mechanism to specify this include (and hallo-verdantimage.coffee) - within the verdantimages app - - ideally wagtailadmin shouldn't have to know anything at all about verdantimages + TODO: have a mechanism to specify image-chooser.js (and hallo-verdantimage.coffee) + within the wagtailimages app - + ideally wagtailadmin shouldn't have to know anything at all about wagtailimages TODO: a method of injecting these sorts of things on demand when the modal is spawned. {% endcomment %} - - + {% endcompress %} diff --git a/wagtail/wagtailadmin/templatetags/wagtailadmin_nav.py b/wagtail/wagtailadmin/templatetags/wagtailadmin_nav.py index 566845678..34f209e64 100644 --- a/wagtail/wagtailadmin/templatetags/wagtailadmin_nav.py +++ b/wagtail/wagtailadmin/templatetags/wagtailadmin_nav.py @@ -45,9 +45,9 @@ def main_nav(context): request = context['request'] user = request.user - if user.has_perm('verdantimages.add_image'): + if user.has_perm('wagtailimages.add_image'): menu_items.append( - MenuItem('Images', urlresolvers.reverse('verdantimages_index'), classnames='icon icon-image', order=300) + MenuItem('Images', urlresolvers.reverse('wagtailimages_index'), classnames='icon icon-image', order=300) ) if user.has_perm('wagtaildocs.add_document'): menu_items.append( diff --git a/wagtail/wagtailadmin/views/home.py b/wagtail/wagtailadmin/views/home.py index 2f4091fcd..e4a1d281d 100644 --- a/wagtail/wagtailadmin/views/home.py +++ b/wagtail/wagtailadmin/views/home.py @@ -5,7 +5,7 @@ from django.template import RequestContext from django.template.loader import render_to_string from wagtail.wagtailcore.models import Page, PageRevision, UserPagePermissionsProxy -from verdantimages.models import get_image_model +from wagtail.wagtailimages.models import get_image_model from wagtail.wagtaildocs.models import Document from wagtail.wagtailadmin import hooks diff --git a/wagtail/wagtailcore/rich_text.py b/wagtail/wagtailcore/rich_text.py index aa997e2c6..94f7b3cb1 100644 --- a/wagtail/wagtailcore/rich_text.py +++ b/wagtail/wagtailcore/rich_text.py @@ -5,11 +5,11 @@ import re # parsing HTML with regexes LIKE A BOSS. from wagtail.wagtailcore.whitelist import Whitelister from wagtail.wagtailcore.models import Page -# FIXME: we don't really want to import verdantimages within core. +# FIXME: we don't really want to import wagtailimages within core. # For that matter, we probably don't want core to be concerned about translating # HTML for the benefit of the hallo.js editor... -from verdantimages.models import get_image_model -from verdantimages.formats import get_image_format +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.formats import get_image_format from wagtail.wagtaildocs.models import Document diff --git a/wagtail/wagtailimages/__init__.py b/wagtail/wagtailimages/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailimages/admin.py b/wagtail/wagtailimages/admin.py new file mode 100644 index 000000000..243741374 --- /dev/null +++ b/wagtail/wagtailimages/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from django.conf import settings + +from wagtail.wagtailimages.models import Image + +if hasattr(settings, 'WAGTAILIMAGES_IMAGE_MODEL') and settings.WAGTAILIMAGES_IMAGE_MODEL != 'wagtailimages.Image': + # This installation provides its own custom image class; + # to avoid confusion, we won't expose the unused wagtailimages.Image class + # in the admin. + pass +else: + admin.site.register(Image) diff --git a/wagtail/wagtailimages/edit_handlers.py b/wagtail/wagtailimages/edit_handlers.py new file mode 100644 index 000000000..18bebab7c --- /dev/null +++ b/wagtail/wagtailimages/edit_handlers.py @@ -0,0 +1,11 @@ +from wagtail.wagtailadmin.edit_handlers import BaseChooserPanel + +class BaseImageChooserPanel(BaseChooserPanel): + field_template = "wagtailimages/edit_handlers/image_chooser_panel.html" + object_type_name = "image" + js_function_name = "createImageChooser" + +def ImageChooserPanel(field_name): + return type('_ImageChooserPanel', (BaseImageChooserPanel,), { + 'field_name': field_name, + }) diff --git a/wagtail/wagtailimages/formats.py b/wagtail/wagtailimages/formats.py new file mode 100644 index 000000000..de0a85bdc --- /dev/null +++ b/wagtail/wagtailimages/formats.py @@ -0,0 +1,87 @@ +from django.conf import settings +from django.utils.importlib import import_module +from django.utils.html import escape + +class Format(object): + def __init__(self, name, label, classnames, filter_spec): + self.name = name + self.label = label + self.classnames = classnames + self.filter_spec = filter_spec + + def editor_attributes(self, image, alt_text): + """ + Return string of additional attributes to go on the HTML element + when outputting this image within a rich text editor field + """ + return 'data-embedtype="image" data-id="%d" data-format="%s" data-alt="%s" ' % ( + image.id, self.name, alt_text + ) + + def image_to_editor_html(self, image, alt_text): + return self.image_to_html( + image, alt_text, self.editor_attributes(image, alt_text) + ) + + def image_to_html(self, image, alt_text, extra_attributes=''): + rendition = image.get_rendition(self.filter_spec) + + if self.classnames: + class_attr = 'class="%s" ' % escape(self.classnames) + else: + class_attr = '' + + return '%s' % ( + extra_attributes, class_attr, + escape(rendition.url), rendition.width, rendition.height, alt_text + ) + + +FORMATS = [] +FORMATS_BY_NAME = {} + +def register_image_format(format): + if format.name in FORMATS_BY_NAME: + raise KeyError("Image format '%s' is already registered" % format.name) + FORMATS_BY_NAME[format.name] = format + FORMATS.append(format) + +def unregister_image_format(format_name): + global FORMATS + # handle being passed a format object rather than a format name string + try: + format_name = format_name.name + except AttributeError: + pass + + try: + del FORMATS_BY_NAME[format_name] + FORMATS = [fmt for fmt in FORMATS if fmt.name != format_name] + except KeyError: + raise KeyError("Image format '%s' is not registered" % format_name) + +def get_image_formats(): + search_for_image_formats() + return FORMATS + +def get_image_format(name): + search_for_image_formats() + return FORMATS_BY_NAME[name] + +_searched_for_image_formats = False +def search_for_image_formats(): + global _searched_for_image_formats + if not _searched_for_image_formats: + for app_module in settings.INSTALLED_APPS: + try: + import_module('%s.image_formats' % app_module) + except ImportError: + continue + + _searched_for_image_formats = True + + +# Define default image formats +register_image_format(Format('fullwidth', 'Full width', 'full-width', 'width-800')) +register_image_format(Format('left', 'Left-aligned', 'left', 'width-500')) +register_image_format(Format('right', 'Right-aligned', 'right', 'width-500')) diff --git a/wagtail/wagtailimages/forms.py b/wagtail/wagtailimages/forms.py new file mode 100644 index 000000000..fd3345c03 --- /dev/null +++ b/wagtail/wagtailimages/forms.py @@ -0,0 +1,25 @@ +from django import forms +from django.forms.models import modelform_factory + +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.formats import get_image_formats + + +def get_image_form(): + return modelform_factory(get_image_model(), + # set the 'file' widget to a FileInput rather than the default ClearableFileInput + # so that when editing, we don't get the 'currently: ...' banner which is + # a bit pointless here + widgets = {'file': forms.FileInput()}) + + +class ImageInsertionForm(forms.Form): + """ + Form for selecting parameters of the image (e.g. format) prior to insertion + into a rich text area + """ + format = forms.ChoiceField( + choices=[(format.name, format.label) for format in get_image_formats()], + widget=forms.RadioSelect + ) + alt_text = forms.CharField() diff --git a/wagtail/wagtailimages/image_ops.py b/wagtail/wagtailimages/image_ops.py new file mode 100644 index 000000000..ecc7a6526 --- /dev/null +++ b/wagtail/wagtailimages/image_ops.py @@ -0,0 +1,123 @@ +from PIL import Image + +def resize(image, size): + """ + resize image to the requested size, using highest quality settings + (antialiasing enabled, converting to true colour if required) + """ + if image.mode in ['1', 'P']: + image = image.convert('RGB') + + return image.resize(size, Image.ANTIALIAS) + + +def crop_to_centre(image, size): + (original_width, original_height) = image.size + (target_width, target_height) = size + + # final dimensions should not exceed original dimensions + final_width = min(original_width, target_width) + final_height = min(original_height, target_height) + + if final_width == original_width and final_height == original_height: + return image + + left = (original_width - final_width) / 2 + top = (original_height - final_height) / 2 + return image.crop( + (left, top, left + final_width, top + final_height) + ) + + +def resize_to_max(image, size): + """ + Resize image down to fit within the given dimensions, preserving aspect ratio. + Will leave image unchanged if it's already within those dimensions. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width and original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a smaller image + if horz_scale < vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return resize(image, final_size) + + +def resize_to_min(image, size): + """ + Resize image down to cover the given dimensions, preserving aspect ratio. + Will leave image unchanged if width or height is already within those limits. + """ + (original_width, original_height) = image.size + (target_width, target_height) = size + + if original_width <= target_width or original_height <= target_height: + return image + + # scale factor if we were to downsize the image to fit the target width + horz_scale = float(target_width) / original_width + # scale factor if we were to downsize the image to fit the target height + vert_scale = float(target_height) / original_height + + # choose whichever of these gives a larger image + if horz_scale > vert_scale: + final_size = (target_width, int(original_height * horz_scale)) + else: + final_size = (int(original_width * vert_scale), target_height) + + return resize(image, final_size) + + +def resize_to_width(image, target_width): + """ + Resize image down to the given width, preserving aspect ratio. + Will leave image unchanged if it's already within that width. + """ + (original_width, original_height) = image.size + + if original_width <= target_width: + return image + + scale = float(target_width) / original_width + + final_size = (target_width, int(original_height * scale)) + + return resize(image, final_size) + + +def resize_to_height(image, target_height): + """ + Resize image down to the given height, preserving aspect ratio. + Will leave image unchanged if it's already within that height. + """ + (original_width, original_height) = image.size + + if original_height <= target_height: + return image + + scale = float(target_height) / original_height + + final_size = (int(original_width * scale), target_height) + + return resize(image, final_size) + + +def resize_to_fill(image, size): + """ + Resize down and crop image to fill the given dimensions. Most suitable for thumbnails. + (The final image will match the requested size, unless one or the other dimension is + already smaller than the target size) + """ + resized_image = resize_to_min(image, size) + return crop_to_centre(resized_image, size) diff --git a/wagtail/wagtailimages/migrations/0001_initial.py b/wagtail/wagtailimages/migrations/0001_initial.py new file mode 100644 index 000000000..49d0f4a2c --- /dev/null +++ b/wagtail/wagtailimages/migrations/0001_initial.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + depends_on = ( + ("wagtailcore", "0002_initial_data"), + ) + + def forwards(self, orm): + # Adding model 'Image' + db.create_table(u'wagtailimages_image', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('title', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('file', self.gf('django.db.models.fields.files.ImageField')(max_length=100)), + ('width', self.gf('django.db.models.fields.IntegerField')()), + ('height', self.gf('django.db.models.fields.IntegerField')()), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('uploaded_by_user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, blank=True)), + )) + db.send_create_signal(u'wagtailimages', ['Image']) + + # Adding model 'Filter' + db.create_table(u'wagtailimages_filter', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('spec', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + )) + db.send_create_signal(u'wagtailimages', ['Filter']) + + # Adding model 'Rendition' + db.create_table(u'wagtailimages_rendition', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('filter', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['wagtailimages.Filter'])), + ('file', self.gf('django.db.models.fields.files.ImageField')(max_length=100)), + ('width', self.gf('django.db.models.fields.IntegerField')()), + ('height', self.gf('django.db.models.fields.IntegerField')()), + ('image', self.gf('django.db.models.fields.related.ForeignKey')(related_name='renditions', to=orm['wagtailimages.Image'])), + )) + db.send_create_signal(u'wagtailimages', ['Rendition']) + + # Adding unique constraint on 'Rendition', fields ['image', 'filter'] + db.create_unique(u'wagtailimages_rendition', ['image_id', 'filter_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'Rendition', fields ['image', 'filter'] + db.delete_unique(u'wagtailimages_rendition', ['image_id', 'filter_id']) + + # Deleting model 'Image' + db.delete_table(u'wagtailimages_image') + + # Deleting model 'Filter' + db.delete_table(u'wagtailimages_filter') + + # Deleting model 'Rendition' + db.delete_table(u'wagtailimages_rendition') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'wagtailimages.filter': { + 'Meta': {'object_name': 'Filter'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'spec': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + u'wagtailimages.image': { + 'Meta': {'object_name': 'Image'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'file': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'height': ('django.db.models.fields.IntegerField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'width': ('django.db.models.fields.IntegerField', [], {}) + }, + u'wagtailimages.rendition': { + 'Meta': {'unique_together': "(('image', 'filter'),)", 'object_name': 'Rendition'}, + 'file': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'filter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': u"orm['wagtailimages.Filter']"}), + 'height': ('django.db.models.fields.IntegerField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'renditions'", 'to': u"orm['wagtailimages.Image']"}), + 'width': ('django.db.models.fields.IntegerField', [], {}) + } + } + + complete_apps = ['wagtailimages'] \ No newline at end of file diff --git a/wagtail/wagtailimages/migrations/0002_initial_data.py b/wagtail/wagtailimages/migrations/0002_initial_data.py new file mode 100644 index 000000000..659b8c9c2 --- /dev/null +++ b/wagtail/wagtailimages/migrations/0002_initial_data.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + image_content_type, created = orm['contenttypes.ContentType'].objects.get_or_create( + model='image', app_label='wagtailimages', defaults={'name': 'image'}) + add_permission, created = orm['auth.permission'].objects.get_or_create( + content_type=image_content_type, codename='add_image', defaults=dict(name=u'Can add image')) + change_permission, created = orm['auth.permission'].objects.get_or_create( + content_type=image_content_type, codename='change_image', defaults=dict(name=u'Can change image')) + delete_permission, created = orm['auth.permission'].objects.get_or_create( + content_type=image_content_type, codename='delete_image', defaults=dict(name=u'Can delete image')) + + editors_group = orm['auth.group'].objects.get(name='Editors') + editors_group.permissions.add(add_permission, change_permission, delete_permission) + + moderators_group = orm['auth.group'].objects.get(name='Moderators') + moderators_group.permissions.add(add_permission, change_permission, delete_permission) + + def backwards(self, orm): + "Write your backwards methods here." + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + u'wagtailimages.filter': { + 'Meta': {'object_name': 'Filter'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'spec': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}) + }, + u'wagtailimages.image': { + 'Meta': {'object_name': 'Image'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'file': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'height': ('django.db.models.fields.IntegerField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uploaded_by_user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'width': ('django.db.models.fields.IntegerField', [], {}) + }, + u'wagtailimages.rendition': { + 'Meta': {'unique_together': "(('image', 'filter'),)", 'object_name': 'Rendition'}, + 'file': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'filter': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': u"orm['wagtailimages.Filter']"}), + 'height': ('django.db.models.fields.IntegerField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'image': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'renditions'", 'to': u"orm['wagtailimages.Image']"}), + 'width': ('django.db.models.fields.IntegerField', [], {}) + } + } + + complete_apps = ['wagtailimages'] + symmetrical = True diff --git a/wagtail/wagtailimages/migrations/__init__.py b/wagtail/wagtailimages/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py new file mode 100644 index 000000000..2e41f0bed --- /dev/null +++ b/wagtail/wagtailimages/models.py @@ -0,0 +1,241 @@ +from django.core.files import File +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, ValidationError +from django.db import models +from django.db.models.signals import pre_delete +from django.dispatch.dispatcher import receiver +from django.utils.safestring import mark_safe +from django.utils.html import escape + +import StringIO +import PIL.Image +import os.path + +from taggit.managers import TaggableManager + +from wagtail.wagtailadmin.taggable import TagSearchable +from wagtail.wagtailimages import image_ops + + +class AbstractImage(models.Model, TagSearchable): + title = models.CharField(max_length=255) + + def get_upload_to(self, filename): + folder_name = 'original_images' + filename = self.file.field.storage.get_valid_name(filename) + + # replace non-ascii characters in filename with _ , to sidestep issues with filesystem encoding + filename = "".join((i if ord(i)<128 else '_') for i in filename) + + while len(os.path.join(folder_name, filename)) >= 95: + prefix, dot, extension = filename.rpartition('.') + filename = prefix[:-1] + dot + extension + return os.path.join(folder_name, filename) + + def file_extension_validator(ffile): + extension = ffile.name.split(".")[-1].lower() + if extension not in ["gif", "jpg", "jpeg", "png"]: + raise ValidationError("Not a valid image format. Please use a gif, jpeg or png file instead.") + + file = models.ImageField(upload_to=get_upload_to, width_field='width', height_field='height', validators=[file_extension_validator]) + width = models.IntegerField(editable=False) + height = models.IntegerField(editable=False) + created_at = models.DateTimeField(auto_now_add=True) + uploaded_by_user = models.ForeignKey('auth.User', null=True, blank=True, editable=False) + + tags = TaggableManager(help_text=None, blank=True) + + indexed_fields = { + 'uploaded_by_user_id': { + 'type': 'integer', + 'store': 'yes', + 'indexed': 'no', + 'boost': 0, + }, + } + + def __unicode__(self): + return self.title + + def get_rendition(self, filter): + if not hasattr(filter, 'process_image'): + # assume we've been passed a filter spec string, rather than a Filter object + # TODO: keep an in-memory cache of filters, to avoid a db lookup + filter, created = Filter.objects.get_or_create(spec=filter) + + try: + rendition = self.renditions.get(filter=filter) + except ObjectDoesNotExist: + file_field = self.file + generated_image_file = filter.process_image(file_field.file) + + rendition, created = self.renditions.get_or_create( + filter=filter, defaults={'file': generated_image_file}) + + return rendition + + def is_portrait(self): + return (self.width < self.height) + def is_landscape(self): + return (self.height < self.width) + + @property + def filename(self): + return os.path.basename(self.file.name) + + @property + def default_alt_text(self): + # by default the alt text field (used in rich text insertion) is populated + # from the title. Subclasses might provide a separate alt field, and + # override this + return self.title + + def is_editable_by_user(self, user): + if user.has_perm('wagtailimages.change_image'): + # user has global permission to change images + return True + elif user.has_perm('wagtailimages.add_image') and self.uploaded_by_user == user: + # user has image add permission, which also implicitly provides permission to edit their own images + return True + else: + return False + + class Meta: + abstract=True + +class Image(AbstractImage): + pass + + class Meta: + permissions = ( + ('add_image', "Can add image"), + ('change_image', "Can change image"), + ('delete_image', "Can delete image"), + ) + + +# Receive the pre_delete signal and delete the file associated with the model instance. +@receiver(pre_delete, sender=Image) +def image_delete(sender, instance, **kwargs): + # Pass false so FileField doesn't save the model. + instance.file.delete(False) + + +def get_image_model(): + from django.conf import settings + from django.db.models import get_model + + try: + app_label, model_name = settings.WAGTAILIMAGES_IMAGE_MODEL.split('.') + except AttributeError: + return Image + except ValueError: + raise ImproperlyConfigured("WAGTAILIMAGES_IMAGE_MODEL must be of the form 'app_label.model_name'") + + image_model = get_model(app_label, model_name) + if image_model is None: + raise ImproperlyConfigured("WAGTAILIMAGES_IMAGE_MODEL refers to model '%s' that has not been installed" % settings.WAGTAILIMAGES_IMAGE_MODEL) + return image_model + + +class Filter(models.Model): + """ + Represents an operation that can be applied to an Image to produce a rendition + appropriate for final display on the website. Usually this would be a resize operation, + but could potentially involve colour processing, etc. + """ + spec = models.CharField(max_length=255, db_index=True) + + OPERATION_NAMES = { + 'max': image_ops.resize_to_max, + 'min': image_ops.resize_to_min, + 'width': image_ops.resize_to_width, + 'height': image_ops.resize_to_height, + 'fill': image_ops.resize_to_fill, + } + + def __init__(self, *args, **kwargs): + super(Filter, self).__init__(*args, **kwargs) + self.method = None # will be populated when needed, by parsing the spec string + + def _parse_spec_string(self): + # parse the spec string, which is formatted as (method)-(arg), + # and save the results to self.method and self.method_arg + try: + (method_name, method_arg_string) = self.spec.split('-') + self.method = Filter.OPERATION_NAMES[method_name] + + if method_name in ('max', 'min', 'fill'): + # method_arg_string is in the form 640x480 + (width, height) = [int(i) for i in method_arg_string.split('x')] + self.method_arg = (width, height) + else: + # method_arg_string is a single number + self.method_arg = int(method_arg_string) + + except (ValueError, KeyError): + raise ValueError("Invalid image filter spec: %r" % self.spec) + + def process_image(self, input_file): + """ + Given an input image file as a django.core.files.File object, + generate an output image with this filter applied, returning it + as another django.core.files.File object + """ + if not self.method: + self._parse_spec_string() + + input_file.open() + image = PIL.Image.open(input_file) + file_format = image.format + + # perform the resize operation + image = self.method(image, self.method_arg) + + output = StringIO.StringIO() + image.save(output, file_format) + + # generate new filename derived from old one, inserting the filter spec string before the extension + input_filename_parts = os.path.basename(input_file.name).split('.') + filename_without_extension = '.'.join(input_filename_parts[:-1]) + filename_without_extension = filename_without_extension[:60] # trim filename base so that we're well under 100 chars + output_filename_parts = [filename_without_extension, self.spec] + input_filename_parts[-1:] + output_filename = '.'.join(output_filename_parts) + + output_file = File(output, name=output_filename) + input_file.close() + + return output_file + + +class AbstractRendition(models.Model): + filter = models.ForeignKey('Filter', related_name='+') + file = models.ImageField(upload_to='images', width_field='width', height_field='height') + width = models.IntegerField(editable=False) + height = models.IntegerField(editable=False) + + @property + def url(self): + return self.file.url + + def img_tag(self): + return mark_safe( + '%s' % (escape(self.url), self.width, self.height, escape(self.image.title)) + ) + + class Meta: + abstract=True + + +class Rendition(AbstractRendition): + image = models.ForeignKey('Image', related_name='renditions') + + class Meta: + unique_together = ( + ('image', 'filter'), + ) + +# Receive the pre_delete signal and delete the file associated with the model instance. +@receiver(pre_delete, sender=Rendition) +def rendition_delete(sender, instance, **kwargs): + # Pass false so FileField doesn't save the model. + instance.file.delete(False) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/hallo-plugins/hallo-verdantimage.coffee b/wagtail/wagtailimages/static/wagtailimages/js/hallo-plugins/hallo-verdantimage.coffee new file mode 100644 index 000000000..57f097867 --- /dev/null +++ b/wagtail/wagtailimages/static/wagtailimages/js/hallo-plugins/hallo-verdantimage.coffee @@ -0,0 +1,39 @@ +# plugin for hallo.js to allow inserting images from the Verdant image library + +(($) -> + $.widget "IKS.halloverdantimage", + options: + uuid: '' + editable: null + + populateToolbar: (toolbar) -> + widget = this + + # Create an element for holding the button + button = $('') + button.hallobutton + uuid: @options.uuid + editable: @options.editable + label: 'Images' + icon: 'icon-picture' + command: null + + # Append the button to toolbar + toolbar.append button + + button.on "click", (event) -> + lastSelection = widget.options.editable.getSelection() + insertionPoint = $(lastSelection.endContainer).parentsUntil('.richtext').last() + ModalWorkflow + url: '/admin/images/chooser/?select_format=true' # TODO: don't hard-code this, as it may be changed in urls.py + responses: + imageChosen: (imageData) -> + elem = $(imageData.html).get(0) + + lastSelection.insertNode(elem) + + if elem.getAttribute('contenteditable') == 'false' + insertRichTextDeleteControl(elem) + widget.options.editable.element.trigger('change') + +)(jQuery) diff --git a/wagtail/wagtailimages/static/wagtailimages/js/image-chooser.js b/wagtail/wagtailimages/static/wagtailimages/js/image-chooser.js new file mode 100644 index 000000000..cd340b1ca --- /dev/null +++ b/wagtail/wagtailimages/static/wagtailimages/js/image-chooser.js @@ -0,0 +1,28 @@ +function createImageChooser(id) { + var chooserElement = $('#' + id + '-chooser'); + var previewImage = chooserElement.find('.preview-image img'); + var input = $('#' + id); + + $('.action-choose', chooserElement).click(function() { + ModalWorkflow({ + 'url': '/admin/images/chooser/', /* TODO: don't hard-code this, as it may be changed in urls.py */ + 'responses': { + 'imageChosen': function(imageData) { + input.val(imageData.id); + previewImage.attr({ + 'src': imageData.preview.url, + 'width': imageData.preview.width, + 'height': imageData.preview.height, + 'alt': imageData.title + }); + chooserElement.removeClass('blank'); + } + } + }); + }); + + $('.action-clear', chooserElement).click(function() { + input.val(''); + chooserElement.addClass('blank'); + }); +} diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html new file mode 100644 index 000000000..3736c818d --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html @@ -0,0 +1,49 @@ +{% load image_tags ellipsistrim%} + +
+

Choose an image

+
+ +{% if uploadform %} + +{% endif %} + +
+ + {% if uploadform %} +
+
+ {% csrf_token %} +
    + {% for field in uploadform %} + {% include "wagtailadmin/shared/field_as_li.html" with field=field %} + {% endfor %} +
  • +
+
+
+ {% endif %} +
diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.js b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.js new file mode 100644 index 000000000..18ff4c5c2 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.js @@ -0,0 +1,85 @@ +function(modal) { + function ajaxifyLinks (context) { + $('.listing a', context).click(function() { + modal.loadUrl(this.href); + return false; + }); + + $('.pagination a', context).click(function() { + var page = this.getAttribute("data-page"); + setPage(page); + return false; + }); + } + + var searchUrl = $('form.image-search', modal.body).attr('action'); + function search() { + $.ajax({ + url: searchUrl, + data: {q: $('#id_q').val()}, + success: function(data, status) { + $('#image-results').html(data); + ajaxifyLinks($('#image-results')); + } + }); + return false; + } + function setPage(page) { + + if($('#id_q').val().length){ + dataObj = {q: $('#id_q').val(), p: page}; + }else{ + dataObj = {p: page}; + } + + $.ajax({ + url: searchUrl, + data: dataObj, + success: function(data, status) { + $('#image-results').html(data); + ajaxifyLinks($('#image-results')); + } + }); + return false; + } + + ajaxifyLinks(modal.body); + + $('form.image-upload', modal.body).submit(function() { + var formdata = new FormData(this); + + $.ajax({ + url: this.action, + data: formdata, + processData: false, + contentType: false, + type: 'POST', + dataType: 'text', + success: function(response){ + modal.loadResponseText(response); + } + }); + + return false; + }); + + $('form.image-search', modal.body).submit(search); + + $('#id_q').on('input', function() { + clearTimeout($.data(this, 'timer')); + var wait = setTimeout(search, 200); + $(this).data('timer', wait); + }); + $('a.suggested-tag').click(function() { + $('#id_q').val($(this).text()); + search(); + return false; + }); + + {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} + + /* Add tag entry interface (with autocompletion) to the tag field of the image upload form */ + $('#id_tags', modal.body).tagit({ + autocomplete: {source: "{{ autocomplete_url|addslashes }}"} + }); +} diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/image_chosen.js b/wagtail/wagtailimages/templates/wagtailimages/chooser/image_chosen.js new file mode 100644 index 000000000..841d12f5c --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/image_chosen.js @@ -0,0 +1,4 @@ +function(modal) { + modal.respond('imageChosen', {{ image_json|safe }}); + modal.close(); +} diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html new file mode 100644 index 000000000..135900608 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html @@ -0,0 +1,22 @@ +{% load image_tags ellipsistrim %} + +{% if images %} + {% if is_searching %} +

{{ images.paginator.count }} match{{ images.paginator.count|pluralize:"es" }}

+ {% else %} +

Latest images

+ {% endif %} + + + + {% include "wagtailadmin/shared/pagination_nav.html" with items=images is_ajax=1 %} +{% endif %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html new file mode 100644 index 000000000..452bdf0a3 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html @@ -0,0 +1,22 @@ +{% load image_tags %} + +
+

Choose a format

+
+ +
+
+ {% image image max-800x600 %} +
+
+
+ {% csrf_token %} +
    + {% for field in form %} + {% include "wagtailadmin/shared/field_as_li.html" with field=field %} + {% endfor %} +
  • +
+
+
+
\ No newline at end of file diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.js b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.js new file mode 100644 index 000000000..1ad6b39e3 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.js @@ -0,0 +1,11 @@ +function(modal) { + $('form', modal.body).submit(function() { + var formdata = new FormData(this); + + $.post(this.action, $(this).serialize(), function(response){ + modal.loadResponseText(response); + }, 'text'); + + return false; + }); +} diff --git a/wagtail/wagtailimages/templates/wagtailimages/edit_handlers/image_chooser_panel.html b/wagtail/wagtailimages/templates/wagtailimages/edit_handlers/image_chooser_panel.html new file mode 100644 index 000000000..b521c992a --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/edit_handlers/image_chooser_panel.html @@ -0,0 +1,18 @@ +{% extends "wagtailadmin/edit_handlers/chooser_panel.html" %} +{% load image_tags %} + +{% block chooser_class %}image-chooser{% endblock %} + +{% block chosen_state_view %} +
+ {% if image %} + {% image image max-130x130 %} + {% else %} + + {% endif %} +
+{% endblock %} + +{% block clear_button_label %}Clear image{% endblock %} +{% block choose_another_button_label %}Choose another image{% endblock %} +{% block choose_button_label %}Choose an image{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/_file_field.html b/wagtail/wagtailimages/templates/wagtailimages/images/_file_field.html new file mode 100644 index 000000000..d8a206188 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/_file_field.html @@ -0,0 +1,8 @@ +{% extends "wagtailadmin/shared/field_as_li.html" %} + +{% block form_field %} + {{ image.filename }}

+ + Change image: + {{ field }} +{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/add.html b/wagtail/wagtailimages/templates/wagtailimages/images/add.html new file mode 100644 index 000000000..d22c2ab5b --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/add.html @@ -0,0 +1,29 @@ +{% extends "wagtailadmin/base.html" %} +{% load image_tags %} +{% block titletag %}Add an image{% endblock %} +{% block bodyclass %}menu-images{% endblock %} +{% block extra_css %} + {% include "wagtailadmin/shared/tag_field_css.html" %} +{% endblock %} + +{% block extra_js %} + {% include "wagtailadmin/shared/tag_field_js.html" %} +{% endblock %} + +{% block content %} +
+

Add image

+
+ +
+
+ {% csrf_token %} + +
+
+{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html b/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html new file mode 100644 index 000000000..eaba88964 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html @@ -0,0 +1,22 @@ +{% extends "wagtailadmin/base.html" %} +{% load image_tags %} +{% block titletag %}Delete image{% endblock %} +{% block bodyclass %}menu-images{% endblock %} + +{% block content %} +
+

Delete image

+
+
+
+ {% image image max-800x600 %} +
+
+

Are you sure you want to delete this image?

+
+ {% csrf_token %} + +
+
+
+{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/edit.html b/wagtail/wagtailimages/templates/wagtailimages/images/edit.html new file mode 100644 index 000000000..1c3342377 --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/edit.html @@ -0,0 +1,41 @@ +{% extends "wagtailadmin/base.html" %} +{% load image_tags %} +{% block titletag %}Editing image {{ image.title }}{% endblock %} +{% block bodyclass %}menu-images{% endblock %} +{% block extra_css %} + {% include "wagtailadmin/shared/tag_field_css.html" %} +{% endblock %} + +{% block extra_js %} + {% include "wagtailadmin/shared/tag_field_js.html" %} +{% endblock %} + +{% block content %} +
+

Editing {{ image.title }}

+
+
+ +
+
+ {% csrf_token %} +
    + {% for field in form %} + + {% if field.name == 'file' %} + {% include "wagtailimages/images/_file_field.html" %} + {% else %} + {% include "wagtailadmin/shared/field_as_li.html" %} + {% endif %} + + {% endfor %} +
  • Delete image
  • +
+
+ +
+
+ {% image image max-800x600 %} +
+
+{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/index.html b/wagtail/wagtailimages/templates/wagtailimages/images/index.html new file mode 100644 index 000000000..7a2ee856b --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/index.html @@ -0,0 +1,74 @@ +{% extends "wagtailadmin/base.html" %} +{% load image_tags ellipsistrim %} + +{% block titletag %}Images{% endblock %} +{% block bodyclass %}menu-images{% endblock %} +{% block extra_js %} + +{% endblock %} + +{% block content %} +
+
+
+

Images

+
+ +
+
+ + + +
+
+ {% include "wagtailimages/images/results.html" %} +
+
+{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/results.html b/wagtail/wagtailimages/templates/wagtailimages/images/results.html new file mode 100644 index 000000000..6dfe68cdf --- /dev/null +++ b/wagtail/wagtailimages/templates/wagtailimages/images/results.html @@ -0,0 +1,29 @@ +{% load image_tags ellipsistrim %} + +{% if images %} + {% if is_searching %} +

{{ images.paginator.count }} match{{ images.paginator.count|pluralize:"es" }}

+ {% else %} +

Latest images

+ {% endif %} + + + + {% include "wagtailadmin/shared/pagination_nav.html" with items=images is_searching=is_searching search_query=search_query linkurl="wagtailimages_index" %} + +{% else %} + {% if is_searching %} +

Sorry, no images match "{{ search_query }}"

+ {% else %} +

You've not uploaded any images. Why not add one now?

+ {% endif %} +{% endif %} diff --git a/wagtail/wagtailimages/templatetags/__init__.py b/wagtail/wagtailimages/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailimages/templatetags/image_tags.py b/wagtail/wagtailimages/templatetags/image_tags.py new file mode 100644 index 000000000..0fee50ec9 --- /dev/null +++ b/wagtail/wagtailimages/templatetags/image_tags.py @@ -0,0 +1,51 @@ +from django import template + +from wagtail.wagtailimages.models import Filter + +register = template.Library() + +@register.tag(name="image") +def image(parser, token): + args = token.split_contents() + + if len(args) == 3: + # token is of the form {% image self.photo max-320x200 %} + tag_name, image_var, filter_spec = args + return ImageNode(image_var, filter_spec) + + elif len(args) == 5: + # token is of the form {% image self.photo max-320x200 as img %} + tag_name, image_var, filter_spec, as_token, out_var = args + + if as_token != 'as': + raise template.TemplateSyntaxError("'image' tag should be of the form {%% image self.photo max-320x200 %%} or {%% image self.photo max-320x200 as img %%}") + + return ImageNode(image_var, filter_spec, out_var) + + else: + raise template.TemplateSyntaxError("'image' tag should be of the form {%% image self.photo max-320x200 %%} or {%% image self.photo max-320x200 as img %%}") + +class ImageNode(template.Node): + def __init__(self, image_var_name, filter_spec, output_var_name=None): + self.image_var = template.Variable(image_var_name) + self.filter, created = Filter.objects.get_or_create(spec=filter_spec) + self.output_var_name = output_var_name + + def render(self, context): + try: + image = self.image_var.resolve(context) + except template.VariableDoesNotExist: + return '' + + if not image: + return '' + + rendition = image.get_rendition(self.filter) + + if self.output_var_name: + # return the rendition object in the given variable + context[self.output_var_name] = rendition + return '' + else: + # render the rendition's image tag now + return rendition.img_tag() diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py new file mode 100644 index 000000000..501deb776 --- /dev/null +++ b/wagtail/wagtailimages/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/wagtail/wagtailimages/urls.py b/wagtail/wagtailimages/urls.py new file mode 100644 index 000000000..206974f97 --- /dev/null +++ b/wagtail/wagtailimages/urls.py @@ -0,0 +1,15 @@ +from django.conf.urls import patterns, url + + +urlpatterns = patterns('wagtail.wagtailimages.views', + url(r'^$', 'images.index', name='wagtailimages_index'), + url(r'^(\d+)/$', 'images.edit', name='wagtailimages_edit_image'), + url(r'^(\d+)/delete/$', 'images.delete', name='wagtailimages_delete_image'), + url(r'^add/$', 'images.add', name='wagtailimages_add_image'), + url(r'^search/$', 'images.search', name='wagtailimages_search_image'), + + url(r'^chooser/$', 'chooser.chooser', name='wagtailimages_chooser'), + url(r'^chooser/(\d+)/$', 'chooser.image_chosen', name='wagtailimages_image_chosen'), + url(r'^chooser/upload/$', 'chooser.chooser_upload', name='wagtailimages_chooser_upload'), + url(r'^chooser/(\d+)/select_format/$', 'chooser.chooser_select_format', name='wagtailimages_chooser_select_format'), +) diff --git a/wagtail/wagtailimages/views/__init__.py b/wagtail/wagtailimages/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/wagtailimages/views/chooser.py b/wagtail/wagtailimages/views/chooser.py new file mode 100644 index 000000000..71f26c45d --- /dev/null +++ b/wagtail/wagtailimages/views/chooser.py @@ -0,0 +1,177 @@ +from django.shortcuts import get_object_or_404, render +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.contrib.auth.decorators import login_required, permission_required + +import json + +from wagtail.wagtailadmin.modal_workflow import render_modal_workflow +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.forms import get_image_form, ImageInsertionForm +from wagtail.wagtailadmin.forms import SearchForm +from wagtail.wagtailimages.formats import get_image_format + + +def get_image_json(image): + """ + helper function: given an image, return the json to pass back to the + image chooser panel + """ + preview_image = image.get_rendition('max-130x100') + + return json.dumps({ + 'id': image.id, + 'title': image.title, + 'preview': { + 'url': preview_image.url, + 'width': preview_image.width, + 'height': preview_image.height, + } + }) + +@login_required +def chooser(request): + Image = get_image_model() + + if request.user.has_perm('wagtailimages.add_image'): + ImageForm = get_image_form() + uploadform = ImageForm() + else: + uploadform = None + + q = None + if 'q' in request.GET or 'p' in request.GET: + searchform = SearchForm(request.GET) + if searchform.is_valid(): + q = searchform.cleaned_data['q'] + + # page number + p = request.GET.get("p", 1) + + images = Image.search(q, results_per_page=10, page=p) + + is_searching = True + + else: + images = Image.objects.order_by('-created_at') + p = request.GET.get("p", 1) + paginator = Paginator(images, 10) + + try: + images = paginator.page(p) + except PageNotAnInteger: + images = paginator.page(1) + except EmptyPage: + images = paginator.page(paginator.num_pages) + + is_searching = False + + return render(request, "wagtailimages/chooser/results.html", { + 'images': images, + 'is_searching': is_searching, + 'will_select_format': request.GET.get('select_format') + }) + else: + searchform = SearchForm() + + images = Image.objects.order_by('-created_at') + p = request.GET.get("p", 1) + paginator = Paginator(images, 10) + + try: + images = paginator.page(p) + except PageNotAnInteger: + images = paginator.page(1) + except EmptyPage: + images = paginator.page(paginator.num_pages) + + + return render_modal_workflow(request, 'wagtailimages/chooser/chooser.html', 'wagtailimages/chooser/chooser.js',{ + 'images': images, + 'uploadform': uploadform, + 'searchform': searchform, + 'is_searching': False, + 'will_select_format': request.GET.get('select_format'), + 'popular_tags': Image.popular_tags(), + }) + + +@login_required +def image_chosen(request, image_id): + image = get_object_or_404(get_image_model(), id=image_id) + + return render_modal_workflow( + request, None, 'wagtailimages/chooser/image_chosen.js', + {'image_json': get_image_json(image)} + ) + + +@permission_required('wagtailimages.add_image') +def chooser_upload(request): + Image = get_image_model() + ImageForm = get_image_form() + + if request.POST: + image = Image(uploaded_by_user=request.user) + form = ImageForm(request.POST, request.FILES, instance=image) + + if form.is_valid(): + form.save() + if request.GET.get('select_format'): + form = ImageInsertionForm(initial={'alt_text': image.default_alt_text}) + return render_modal_workflow( + request, 'wagtailimages/chooser/select_format.html', 'wagtailimages/chooser/select_format.js', + {'image': image, 'form': form} + ) + else: + # not specifying a format; return the image details now + return render_modal_workflow( + request, None, 'wagtailimages/chooser/image_chosen.js', + {'image_json': get_image_json(image)} + ) + else: + form = ImageForm() + + images = Image.objects.order_by('title') + + return render_modal_workflow( + request, 'wagtailimages/chooser/chooser.html', 'wagtailimages/chooser/chooser.js', + {'images': images, 'uploadform': form} + ) + + +@login_required +def chooser_select_format(request, image_id): + image = get_object_or_404(get_image_model(), id=image_id) + + if request.POST: + form = ImageInsertionForm(request.POST, initial={'alt_text': image.default_alt_text}) + if form.is_valid(): + + format = get_image_format(form.cleaned_data['format']) + preview_image = image.get_rendition(format.filter_spec) + + image_json = json.dumps({ + 'id': image.id, + 'title': image.title, + 'format': format.name, + 'alt': form.cleaned_data['alt_text'], + 'class': format.classnames, + 'preview': { + 'url': preview_image.url, + 'width': preview_image.width, + 'height': preview_image.height, + }, + 'html': format.image_to_editor_html(image, form.cleaned_data['alt_text']), + }) + + return render_modal_workflow( + request, None, 'wagtailimages/chooser/image_chosen.js', + {'image_json': image_json} + ) + else: + form = ImageInsertionForm(initial={'alt_text': image.default_alt_text}) + + return render_modal_workflow( + request, 'wagtailimages/chooser/select_format.html', 'wagtailimages/chooser/select_format.js', + {'image': image, 'form': form} + ) diff --git a/wagtail/wagtailimages/views/images.py b/wagtail/wagtailimages/views/images.py new file mode 100644 index 000000000..f8d9eeb13 --- /dev/null +++ b/wagtail/wagtailimages/views/images.py @@ -0,0 +1,173 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib import messages +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.contrib.auth.decorators import permission_required, login_required +from django.core.exceptions import PermissionDenied + +from wagtail.wagtailimages.models import get_image_model +from wagtail.wagtailimages.forms import get_image_form +from wagtail.wagtailadmin.forms import SearchForm + +@permission_required('wagtailimages.add_image') +def index(request): + Image = get_image_model() + + q = None + p = request.GET.get("p", 1) + is_searching = False + + if 'q' in request.GET: + form = SearchForm(request.GET) + if form.is_valid(): + q = form.cleaned_data['q'] + + is_searching = True + if not request.user.has_perm('wagtailimages.change_image'): + # restrict to the user's own images + images = Image.search(q, results_per_page=20, page=p, filters={'uploaded_by_user_id': request.user.id}) + else: + images = Image.search(q, results_per_page=20, page=p) + else: + images = Image.objects.order_by('-created_at') + if not request.user.has_perm('wagtailimages.change_image'): + # restrict to the user's own images + images = images.filter(uploaded_by_user=request.user) + else: + images = Image.objects.order_by('-created_at') + if not request.user.has_perm('wagtailimages.change_image'): + # restrict to the user's own images + images = images.filter(uploaded_by_user=request.user) + form = SearchForm() + + if not is_searching: + paginator = Paginator(images, 20) + + try: + images = paginator.page(p) + except PageNotAnInteger: + images = paginator.page(1) + except EmptyPage: + images = paginator.page(paginator.num_pages) + + if request.is_ajax(): + return render(request, "wagtailimages/images/results.html", { + 'images': images, + 'is_searching': is_searching, + 'search_query': q, + }) + else: + return render(request, "wagtailimages/images/index.html", { + 'form': form, + 'images': images, + 'is_searching': is_searching, + 'popular_tags': Image.popular_tags(), + 'search_query': q, + }) + + +@login_required # more specific permission tests are applied within the view +def edit(request, image_id): + Image = get_image_model() + ImageForm = get_image_form() + + image = get_object_or_404(Image, id=image_id) + + if not image.is_editable_by_user(request.user): + raise PermissionDenied + + if request.POST: + original_file = image.file + form = ImageForm(request.POST, request.FILES, instance=image) + if form.is_valid(): + if 'file' in form.changed_data: + # if providing a new image file, delete the old one and all renditions. + # NB Doing this via original_file.delete() clears the file field, + # which definitely isn't what we want... + original_file.storage.delete(original_file.name) + image.renditions.all().delete() + form.save() + messages.success(request, "Image '%s' updated." % image.title) + return redirect('wagtailimages_index') + else: + messages.error(request, "The image could not be saved due to errors.") + else: + form = ImageForm(instance=image) + + return render(request, "wagtailimages/images/edit.html", { + 'image': image, + 'form': form, + }) + + +@login_required # more specific permission tests are applied within the view +def delete(request, image_id): + image = get_object_or_404(get_image_model(), id=image_id) + + if not image.is_editable_by_user(request.user): + raise PermissionDenied + + if request.POST: + image.delete() + messages.success(request, "Image '%s' deleted." % image.title) + return redirect('wagtailimages_index') + + return render(request, "wagtailimages/images/confirm_delete.html", { + 'image': image, + }) + + +@permission_required('wagtailimages.add_image') +def add(request): + ImageForm = get_image_form() + ImageModel = get_image_model() + + if request.POST: + image = ImageModel(uploaded_by_user=request.user) + form = ImageForm(request.POST, request.FILES, instance=image) + if form.is_valid(): + form.save() + messages.success(request, "Image '%s' added." % image.title) + return redirect('wagtailimages_index') + else: + messages.error(request, "The image could not be created due to errors.") + else: + form = ImageForm() + + return render(request, "wagtailimages/images/add.html", { + 'form': form, + }) + + +@permission_required('wagtailimages.add_image') +def search(request): + Image = get_image_model() + images = [] + q = None + is_searching = False + + if 'q' in request.GET: + form = SearchForm(request.GET) + if form.is_valid(): + q = form.cleaned_data['q'] + + # page number + p = request.GET.get("p", 1) + is_searching = True + images = Image.search(q, results_per_page=20, page=p) + else: + form = SearchForm() + + if request.is_ajax(): + return render(request, "wagtailimages/images/results.html", { + 'images': images, + 'is_searching': is_searching, + 'search_query': q, + }) + else: + return render(request, "wagtailimages/images/index.html", { + 'form': form, + 'images': images, + 'is_searching': is_searching, + 'popular_tags': Image.popular_tags(), + 'search_query': q, + })