diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index d7f66ec93..579003000 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -3,12 +3,15 @@ Changelog
0.5 (xx.xx.20xx)
~~~~~~~~~~~~~~~~
+ * Added multiple image uploader
* Added RoutablePage model to allow embedding Django-style URL routing within a page
* Explorer nav now rendered separately and fetched with AJAX when needed
* Added decorator syntax for hooks
* Replaced lxml dependency with html5lib, to simplify installation
* Added page_unpublished signal
+ * Fix: Updates to tag fields are now properly committed to the database when publishing directly from the page edit interface
+
0.4.1 (14.07.2014)
~~~~~~~~~~~~~~~~~~
* ElasticSearch backend now respects the backward-compatible URLS configuration setting, in addition to HOSTS
diff --git a/docs/releases/0.5.rst b/docs/releases/0.5.rst
index a0b52313e..76b0762de 100644
--- a/docs/releases/0.5.rst
+++ b/docs/releases/0.5.rst
@@ -10,6 +10,12 @@ Wagtail 0.5 release notes - IN DEVELOPMENT
What's new
==========
+Multiple image uploader
+~~~~~~~~~~~~~~~~~~~~~~~
+
+The image uploader UI has been improved to allow multiples to be uploaded quickly.
+
+
RoutablePage
~~~~~~~~~~~~
@@ -49,6 +55,8 @@ Admin
Bug fixes
~~~~~~~~~
+ * Updates to tag fields are now properly committed to the database when publishing directly from the page edit interface.
+
Backwards incompatible changes
==============================
diff --git a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
index 5877bb7ed..abdb887ce 100644
--- a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
+++ b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
@@ -24,6 +24,7 @@
{% trans "Drag and drop images into this area to upload immediately." %}
+
+
+
+
+
+
0%
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+ {% compress js %}
+
+
+
+
+
+
+
+
+
+
+
+ {% endcompress %}
+
+ {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html
new file mode 100644
index 000000000..9fdebacc1
--- /dev/null
+++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/edit_form.html
@@ -0,0 +1,14 @@
+{% load i18n %}
+
+
\ No newline at end of file
diff --git a/wagtail/wagtailimages/tests.py b/wagtail/wagtailimages/tests.py
index e8243a4c2..2927cc70d 100644
--- a/wagtail/wagtailimages/tests.py
+++ b/wagtail/wagtailimages/tests.py
@@ -1,3 +1,5 @@
+import json
+
from mock import MagicMock
from django.utils import six
@@ -523,3 +525,209 @@ class TestUsedBy(TestCase):
event_page_carousel_item.image = self.image
event_page_carousel_item.save()
self.assertTrue(issubclass(Page, type(self.image.used_by[0])))
+
+
+class TestMultipleImageUploader(TestCase, WagtailTestUtils):
+ """
+ This tests the multiple image upload views located in wagtailimages/views/multiple.py
+ """
+ def setUp(self):
+ self.login()
+
+ # Create an image for running tests on
+ self.image = Image.objects.create(
+ title="Test image",
+ file=get_test_image_file(),
+ )
+
+ def test_add(self):
+ """
+ This tests that the add view responds correctly on a GET request
+ """
+ # Send request
+ response = self.client.get(reverse('wagtailimages_add_multiple'))
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'wagtailimages/multiple/add.html')
+
+ def test_add_post(self):
+ """
+ This tests that a POST request to the add view saves the image and returns an edit form
+ """
+ response = self.client.post(reverse('wagtailimages_add_multiple'), {
+ 'files[]': SimpleUploadedFile('test.png', get_test_image_file().file.getvalue()),
+ }, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+ self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html')
+
+ # Check image
+ self.assertIn('image', response.context)
+ self.assertEqual(response.context['image'].title, 'test.png')
+
+ # Check form
+ self.assertIn('form', response.context)
+ self.assertEqual(response.context['form'].initial['title'], 'test.png')
+
+ # Check JSON
+ response_json = json.loads(response.content.decode())
+ self.assertIn('image_id', response_json)
+ self.assertIn('form', response_json)
+ self.assertIn('success', response_json)
+ self.assertEqual(response_json['image_id'], response.context['image'].id)
+ self.assertTrue(response_json['success'])
+
+ def test_add_post_noajax(self):
+ """
+ This tests that only AJAX requests are allowed to POST to the add view
+ """
+ response = self.client.post(reverse('wagtailimages_add_multiple'), {})
+
+ # Check response
+ self.assertEqual(response.status_code, 400)
+
+ def test_add_post_nofile(self):
+ """
+ This tests that the add view checks for a file when a user POSTs to it
+ """
+ response = self.client.post(reverse('wagtailimages_add_multiple'), {}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 400)
+
+ def test_add_post_badfile(self):
+ """
+ This tests that the add view checks for a file when a user POSTs to it
+ """
+ response = self.client.post(reverse('wagtailimages_add_multiple'), {
+ 'files[]': SimpleUploadedFile('test.png', b"This is not an image!"),
+ }, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+
+ # Check JSON
+ response_json = json.loads(response.content.decode())
+ self.assertNotIn('image_id', response_json)
+ self.assertNotIn('form', response_json)
+ self.assertIn('success', response_json)
+ self.assertIn('error_message', response_json)
+ self.assertFalse(response_json['success'])
+ self.assertEqual(response_json['error_message'], 'Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).')
+
+ def test_edit_get(self):
+ """
+ This tests that a GET request to the edit view returns a 405 "METHOD NOT ALLOWED" response
+ """
+ # Send request
+ response = self.client.get(reverse('wagtailimages_edit_multiple', args=(self.image.id, )))
+
+ # Check response
+ self.assertEqual(response.status_code, 405)
+
+ def test_edit_post(self):
+ """
+ This tests that a POST request to the edit view edits the image
+ """
+ # Send request
+ response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
+ ('image-%d-title' % self.image.id): "New title!",
+ ('image-%d-tags' % self.image.id): "",
+ }, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+
+ # Check JSON
+ response_json = json.loads(response.content.decode())
+ self.assertIn('image_id', response_json)
+ self.assertNotIn('form', response_json)
+ self.assertIn('success', response_json)
+ self.assertEqual(response_json['image_id'], self.image.id)
+ self.assertTrue(response_json['success'])
+
+ def test_edit_post_noajax(self):
+ """
+ This tests that a POST request to the edit view without AJAX returns a 400 response
+ """
+ # Send request
+ response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
+ ('image-%d-title' % self.image.id): "New title!",
+ ('image-%d-tags' % self.image.id): "",
+ })
+
+ # Check response
+ self.assertEqual(response.status_code, 400)
+
+ def test_edit_post_validation_error(self):
+ """
+ This tests that a POST request to the edit page returns a json document with "success=False"
+ and a form with the validation error indicated
+ """
+ # Send request
+ response = self.client.post(reverse('wagtailimages_edit_multiple', args=(self.image.id, )), {
+ ('image-%d-title' % self.image.id): "", # Required
+ ('image-%d-tags' % self.image.id): "",
+ }, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+ self.assertTemplateUsed(response, 'wagtailimages/multiple/edit_form.html')
+
+ # Check that a form error was raised
+ self.assertFormError(response, 'form', 'title', "This field is required.")
+
+ # Check JSON
+ response_json = json.loads(response.content.decode())
+ self.assertIn('image_id', response_json)
+ self.assertIn('form', response_json)
+ self.assertIn('success', response_json)
+ self.assertEqual(response_json['image_id'], self.image.id)
+ self.assertFalse(response_json['success'])
+
+ def test_delete_get(self):
+ """
+ This tests that a GET request to the delete view returns a 405 "METHOD NOT ALLOWED" response
+ """
+ # Send request
+ response = self.client.get(reverse('wagtailimages_delete_multiple', args=(self.image.id, )))
+
+ # Check response
+ self.assertEqual(response.status_code, 405)
+
+ def test_delete_post(self):
+ """
+ This tests that a POST request to the delete view deletes the image
+ """
+ # Send request
+ response = self.client.post(reverse('wagtailimages_delete_multiple', args=(self.image.id, )), HTTP_X_REQUESTED_WITH='XMLHttpRequest')
+
+ # Check response
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response['Content-Type'], 'application/json')
+
+ # Make sure the image is deleted
+ self.assertFalse(Image.objects.filter(id=self.image.id).exists())
+
+ # Check JSON
+ response_json = json.loads(response.content.decode())
+ self.assertIn('image_id', response_json)
+ self.assertIn('success', response_json)
+ self.assertEqual(response_json['image_id'], self.image.id)
+ self.assertTrue(response_json['success'])
+
+ def test_edit_post_noajax2(self):
+ """
+ This tests that a POST request to the delete view without AJAX returns a 400 response
+ """
+ # Send request
+ response = self.client.post(reverse('wagtailimages_delete_multiple', args=(self.image.id, )))
+
+ # Check response
+ self.assertEqual(response.status_code, 400)
diff --git a/wagtail/wagtailimages/urls.py b/wagtail/wagtailimages/urls.py
index af2da0e34..097742839 100644
--- a/wagtail/wagtailimages/urls.py
+++ b/wagtail/wagtailimages/urls.py
@@ -1,5 +1,5 @@
from django.conf.urls import url
-from wagtail.wagtailimages.views import images, chooser
+from wagtail.wagtailimages.views import images, chooser, multiple
urlpatterns = [
url(r'^$', images.index, name='wagtailimages_index'),
@@ -8,6 +8,10 @@ urlpatterns = [
url(r'^add/$', images.add, name='wagtailimages_add_image'),
url(r'^usage/(\d+)/$', images.usage, name='wagtailimages_image_usage'),
+ url(r'^multiple/add/$', multiple.add, name='wagtailimages_add_multiple'),
+ url(r'^multiple/(\d+)/$', multiple.edit, name='wagtailimages_edit_multiple'),
+ url(r'^multiple/(\d+)/delete/$', multiple.delete, name='wagtailimages_delete_multiple'),
+
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'),
diff --git a/wagtail/wagtailimages/utils.py b/wagtail/wagtailimages/utils.py
index 2f0c0e676..4d10e2360 100644
--- a/wagtail/wagtailimages/utils.py
+++ b/wagtail/wagtailimages/utils.py
@@ -14,15 +14,22 @@ def validate_image_format(f):
extension = 'jpeg'
if extension not in ['gif', 'jpeg', 'png']:
- raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension."))
+ raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png)."))
if not f.closed:
# Open image file
file_position = f.tell()
f.seek(0)
- image = Image.open(f)
+
+ try:
+ image = Image.open(f)
+ except IOError:
+ # Uploaded file is not even an image file (or corrupted)
+ raise ValidationError(_("Not a valid image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png)."))
+
f.seek(file_position)
# Check that the internal format matches the extension
+ # It is possible to upload PSD files if their extension is set to jpg, png or gif. This should catch them out
if image.format.upper() != extension.upper():
- raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension.") % (extension.upper()))
+ raise ValidationError(_("Not a valid %s image. Please use a gif, jpeg or png file with the correct file extension (*.gif, *.jpg or *.png).") % (extension.upper()))
diff --git a/wagtail/wagtailimages/views/multiple.py b/wagtail/wagtailimages/views/multiple.py
new file mode 100644
index 000000000..7a3702625
--- /dev/null
+++ b/wagtail/wagtailimages/views/multiple.py
@@ -0,0 +1,113 @@
+import json
+
+from django.shortcuts import render, get_object_or_404
+from django.contrib.auth.decorators import permission_required
+from django.views.decorators.http import require_POST
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.views.decorators.vary import vary_on_headers
+from django.http import HttpResponse, HttpResponseBadRequest
+from django.template import RequestContext
+from django.template.loader import render_to_string
+from django.utils.translation import ugettext as _
+
+from wagtail.wagtailimages.models import get_image_model
+from wagtail.wagtailimages.forms import get_image_form_for_multi
+from wagtail.wagtailimages.utils import validate_image_format
+
+
+def json_response(document):
+ return HttpResponse(json.dumps(document), content_type='application/json')
+
+
+@permission_required('wagtailimages.add_image')
+@vary_on_headers('X-Requested-With')
+def add(request):
+ Image = get_image_model()
+ ImageForm = get_image_form_for_multi()
+
+ if request.method == 'POST':
+ if not request.is_ajax():
+ return HttpResponseBadRequest("Cannot POST to this view without AJAX")
+
+ if not request.FILES:
+ return HttpResponseBadRequest("Must upload a file")
+
+ # Check that the uploaded file is valid
+ try:
+ validate_image_format(request.FILES['files[]'])
+ except ValidationError as e:
+ return json_response({
+ 'success': False,
+ 'error_message': '\n'.join(e.messages),
+ })
+
+ # Save it
+ image = Image(uploaded_by_user=request.user, title=request.FILES['files[]'].name, file=request.FILES['files[]'])
+ image.save()
+
+ # Success! Send back an edit form for this image to the user
+ form = ImageForm(instance=image, prefix='image-%d' % image.id)
+
+ return json_response({
+ 'success': True,
+ 'image_id': int(image.id),
+ 'form': render_to_string('wagtailimages/multiple/edit_form.html', {
+ 'image': image,
+ 'form': form,
+ }, context_instance=RequestContext(request)),
+ })
+
+
+ return render(request, 'wagtailimages/multiple/add.html', {})
+
+
+@require_POST
+@permission_required('wagtailadmin.access_admin') # more specific permission tests are applied within the view
+def edit(request, image_id, callback=None):
+ Image = get_image_model()
+ ImageForm = get_image_form_for_multi()
+
+ image = get_object_or_404(Image, id=image_id)
+
+ if not request.is_ajax():
+ return HttpResponseBadRequest("Cannot POST to this view without AJAX")
+
+ if not image.is_editable_by_user(request.user):
+ raise PermissionDenied
+
+ form = ImageForm(request.POST, request.FILES, instance=image, prefix='image-'+image_id)
+
+ if form.is_valid():
+ form.save()
+ return json_response({
+ 'success': True,
+ 'image_id': int(image_id),
+ })
+ else:
+ return json_response({
+ 'success': False,
+ 'image_id': int(image_id),
+ 'form': render_to_string('wagtailimages/multiple/edit_form.html', {
+ 'image': image,
+ 'form': form,
+ }, context_instance=RequestContext(request)),
+ })
+
+
+@require_POST
+@permission_required('wagtailadmin.access_admin') # 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 request.is_ajax():
+ return HttpResponseBadRequest("Cannot POST to this view without AJAX")
+
+ if not image.is_editable_by_user(request.user):
+ raise PermissionDenied
+
+ image.delete()
+
+ return json_response({
+ 'success': True,
+ 'image_id': int(image_id),
+ })
diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html
index f443d1a6a..7fe3558e3 100644
--- a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html
+++ b/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html
@@ -6,12 +6,14 @@
{% include "wagtailadmin/shared/header.html" with title=add_str icon="pick" %}
- {% blocktrans %}
-
Editors picks are a means of recommending specific pages that might not organically come high up in search results. E.g recommending your primary donation page to a user searching with a less common term like "giving".
- {% endblocktrans %}
- {% blocktrans %}
-
The "Search term(s)/phrase" field below must contain the full and exact search for which you wish to provide recommended results, including any misspellings/user error. To help, you can choose from search terms that have been popular with users of your site.
- {% endblocktrans %}
+
+ {% blocktrans %}
+
Editors picks are a means of recommending specific pages that might not organically come high up in search results. E.g recommending your primary donation page to a user searching with a less common term like "giving".
+ {% endblocktrans %}
+ {% blocktrans %}
+
The "Search term(s)/phrase" field below must contain the full and exact search for which you wish to provide recommended results, including any misspellings/user error. To help, you can choose from search terms that have been popular with users of your site.