Repository: wagtail/wagtail

Issue:
Pages can be created with 'blank' title/seo_title comprising only space characters
Like the title says.  At the very least, a string consisting of all white space characters should be rejected.  A user clearing out the seo_title with the spacebar instead of the delete key currently makes something like

```
{% if self.seo_title %}{{ self.seo_title|title }}{% else %}{{ self.title|title }}{% endif %}
```

output a blank title.  I cannot think of a situation where a seo_title consisting of all whitespace characters is actually good for seo.  A whitespace title is equally confusing.

Since the user cannot see that they have entered whitespace for the title they are not able to figure out what is going on either.



Discussion:

Resolving patch:
diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py
--- a/wagtail/wagtailadmin/views/pages.py
+++ b/wagtail/wagtailadmin/views/pages.py
@@ -20,6 +20,7 @@
 
 from wagtail.wagtailcore import hooks
 from wagtail.wagtailcore.models import Page, PageRevision, get_navigation_menu_items
+from wagtail.wagtailcore.validators import validate_not_whitespace
 
 
 @permission_required('wagtailadmin.access_admin')
@@ -160,6 +161,19 @@ def clean_slug(slug):
             return slug
         form.fields['slug'].clean = clean_slug
 
+        # Validate title and seo_title are not entirely whitespace
+        def clean_title(title):
+            validate_not_whitespace(title)
+            return title
+        form.fields['title'].clean = clean_title
+
+        def clean_seo_title(seo_title):
+            if not seo_title:
+                return ''
+            validate_not_whitespace(seo_title)
+            return seo_title
+        form.fields['seo_title'].clean = clean_seo_title
+
         # Stick another validator into the form to check that the scheduled publishing settings are set correctly
         def clean():
             cleaned_data = form_class.clean(form)
@@ -274,6 +288,20 @@ def clean_slug(slug):
             return slug
         form.fields['slug'].clean = clean_slug
 
+        # Validate title and seo_title are not entirely whitespace
+        def clean_title(title):
+            validate_not_whitespace(title)
+            return title
+        form.fields['title'].clean = clean_title
+
+        def clean_seo_title(seo_title):
+            if not seo_title:
+                return ''
+            validate_not_whitespace(seo_title)
+            return seo_title
+
+        form.fields['seo_title'].clean = clean_seo_title
+
         # Stick another validator into the form to check that the scheduled publishing settings are set correctly
         def clean():
             cleaned_data = form_class.clean(form)
diff --git a/wagtail/wagtailcore/validators.py b/wagtail/wagtailcore/validators.py
new file mode 100644
--- /dev/null
+++ b/wagtail/wagtailcore/validators.py
@@ -0,0 +1,15 @@
+import re
+
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext as _
+
+WHITESPACE_RE = re.compile(r'^\s+$')
+
+
+def validate_not_whitespace(value):
+    """
+    Validate that a value isn't all whitespace, for example in title and
+    seo_title
+    """
+    if value and WHITESPACE_RE.match(value):
+        raise ValidationError(_('Value cannot be entirely whitespace characters'))


Test patch:
diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py
--- a/wagtail/wagtailadmin/tests/test_pages_views.py
+++ b/wagtail/wagtailadmin/tests/test_pages_views.py
@@ -186,7 +186,7 @@ def test_create_simplepage_post(self):
 
         # Should be redirected to edit page
         self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(page.id, )))
-       
+
         self.assertEqual(page.title, post_data['title'])
         self.assertIsInstance(page, SimplePage)
         self.assertFalse(page.live)
@@ -331,7 +331,7 @@ def test_create_simplepage_post_submit(self):
 
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
-       
+
         self.assertEqual(page.title, post_data['title'])
         self.assertIsInstance(page, SimplePage)
         self.assertFalse(page.live)
@@ -395,6 +395,20 @@ def test_preview_on_create(self):
         self.assertTrue(response.context['self'].path.startswith(self.root_page.path))
         self.assertEqual(response.context['self'].get_parent(), self.root_page)
 
+    def test_whitespace_titles(self):
+        post_data = {
+            'title': " ",  # Single space on purpose
+            'content': "Some content",
+            'slug': 'hello-world',
+            'action-submit': "Submit",
+            'seo_title': '\t',
+        }
+        response = self.client.post(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)), post_data)
+
+        # Check that a form error was raised
+        self.assertFormError(response, 'form', 'title', "Value cannot be entirely whitespace characters")
+        self.assertFormError(response, 'form', 'seo_title', "Value cannot be entirely whitespace characters")
+
 
 class TestPageEdit(TestCase, WagtailTestUtils):
     def setUp(self):
@@ -445,7 +459,7 @@ def test_page_edit_post(self):
             'slug': 'hello-world',
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to edit page
         self.assertRedirects(response, reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )))
 
@@ -467,7 +481,7 @@ def test_page_edit_post_when_locked(self):
             'slug': 'hello-world',
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Shouldn't be redirected
         self.assertContains(response, "The page could not be saved as it is locked")
 
@@ -556,7 +570,7 @@ def page_published_handler(sender, instance, **kwargs):
             'action-publish': "Publish",
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
 
@@ -658,7 +672,7 @@ def test_page_edit_post_submit(self):
             'action-submit': "Submit",
         }
         response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.child_page.id, )), post_data)
-    
+
         # Should be redirected to explorer
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
 
@@ -1863,7 +1877,7 @@ def test_lock_post_with_good_redirect(self):
         # Check that the page is locked
         self.assertTrue(Page.objects.get(id=self.child_page.id).locked)
 
-    def test_unlock_post_with_bad_redirect(self):
+    def test_lock_post_with_bad_redirect(self):
         response = self.client.post(reverse('wagtailadmin_pages_lock', args=(self.child_page.id, )), {
             'next': 'http://www.google.co.uk'
         })
@@ -1872,7 +1886,7 @@ def test_unlock_post_with_bad_redirect(self):
         self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
 
         # Check that the page is locked
-        self.assertTrue(page.objects.get(id=self.child_page.id).locked)
+        self.assertTrue(Page.objects.get(id=self.child_page.id).locked)
 
     def test_lock_post_bad_page(self):
         response = self.client.post(reverse('wagtailadmin_pages_lock', args=(9999, )))
diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py
--- a/wagtail/wagtailcore/tests/test_page_model.py
+++ b/wagtail/wagtailcore/tests/test_page_model.py
@@ -463,7 +463,7 @@ def test_copy_page_copies_revisions_and_doesnt_change_created_at(self):
 
         # Set the created_at of the revision to a time in the past
         revision = christmas_event.get_latest_revision()
-        revision.created_at = datetime.datetime(2014, 1, 1)
+        revision.created_at = datetime.datetime(2014, 1, 1, 0, 0, 0, tzinfo=pytz.utc)
         revision.save()
 
         # Copy it
diff --git a/wagtail/wagtailcore/tests/test_validators.py b/wagtail/wagtailcore/tests/test_validators.py
new file mode 100644
--- /dev/null
+++ b/wagtail/wagtailcore/tests/test_validators.py
@@ -0,0 +1,14 @@
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+
+from wagtail.wagtailcore.validators import validate_not_whitespace
+
+
+class TestValidators(TestCase):
+
+    def test_not_whitespace(self):
+        validate_not_whitespace('bar')
+
+        for test_value in (' ', '\t', '\r', '\n', '\r\n'):
+            with self.assertRaises(ValidationError):
+                validate_not_whitespace(test_value)
diff --git a/wagtail/wagtailimages/tests/test_admin_views.py b/wagtail/wagtailimages/tests/test_admin_views.py
--- a/wagtail/wagtailimages/tests/test_admin_views.py
+++ b/wagtail/wagtailimages/tests/test_admin_views.py
@@ -441,7 +441,7 @@ def test_delete_post(self):
         self.assertEqual(response_json['image_id'], self.image.id)
         self.assertTrue(response_json['success'])
 
-    def test_edit_post_noajax(self):
+    def test_delete_post_noajax(self):
         """
         This tests that a POST request to the delete view without AJAX returns a 400 response
         """