``. If you need more flexibility, such as multiple formats/templates based on differing requests, you can set up a custom search view.
-.. _editors-picks:
+Custom Search Views
+-------------------
-Indexing Custom Fields & Custom Search Views
---------------------------------------------
-
-This functionality is still under active development to provide a streamlined interface, but take a look at ``wagtail/wagtail/wagtailsearch/views/frontend.py`` if you are interested in coding custom search views.
-
-
-Search Backends
----------------
-
-Wagtail can degrade to a database-backed text search, but we strongly recommend `Elasticsearch`_.
-
-.. _Elasticsearch: http://www.elasticsearch.org/
-
-
-Default DB Backend
-``````````````````
-The default DB search backend uses Django's ``__icontains`` filter.
-
-
-Elasticsearch Backend
-`````````````````````
-Prerequisites are the Elasticsearch service itself and, via pip, the `elasticsearch-py`_ package:
-
-.. code-block:: guess
-
- pip install elasticsearch
-
-.. note::
- If you are using Elasticsearch < 1.0, install elasticsearch-py version 0.4.5: ```pip install elasticsearch==0.4.5```
-
-The backend is configured in settings:
-
-.. code-block:: python
-
- WAGTAILSEARCH_BACKENDS = {
- 'default': {
- 'BACKEND': 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch',
- 'URLS': ['http://localhost:9200'],
- 'INDEX': 'wagtail',
- 'TIMEOUT': 5,
- 'FORCE_NEW': False,
- }
- }
-
-Other than ``BACKEND`` the keys are optional and default to the values shown. ``FORCE_NEW`` is used by elasticsearch-py. In addition, any other keys are passed directly to the Elasticsearch constructor as case-sensitive keyword arguments (e.g. ``'max_retries': 1``).
-
-If you prefer not to run an Elasticsearch server in development or production, there are many hosted services available, including `Searchly`_, who offer a free account suitable for testing and development. To use Searchly:
-
-- Sign up for an account at `dashboard.searchly.com/users/sign\_up`_
-- Use your Searchly dashboard to create a new index, e.g. 'wagtaildemo'
-- Note the connection URL from your Searchly dashboard
-- Configure ``URLS`` and ``INDEX`` in the Elasticsearch entry in ``WAGTAILSEARCH_BACKENDS``
-- Run ``./manage.py update_index``
-
-.. _elasticsearch-py: http://elasticsearch-py.readthedocs.org
-.. _Searchly: http://www.searchly.com/
-.. _dashboard.searchly.com/users/sign\_up: https://dashboard.searchly.com/users/sign_up
-
-Rolling Your Own
-````````````````
-Wagtail search backends implement the interface defined in ``wagtail/wagtail/wagtailsearch/backends/base.py``. At a minimum, the backend's ``search()`` method must return a collection of objects or ``model.objects.none()``. For a fully-featured search backend, examine the Elasticsearch backend code in ``elasticsearch.py``.
+This functionality is still under active development to provide a streamlined interface, but take a look at ``wagtail/wagtail/wagtailsearch/views/frontend.py`` if you are interested in coding custom search views.
\ No newline at end of file
diff --git a/docs/search/index.rst b/docs/search/index.rst
new file mode 100644
index 000000000..e343f76c8
--- /dev/null
+++ b/docs/search/index.rst
@@ -0,0 +1,17 @@
+
+.. _wagtailsearch:
+
+
+Search
+======
+
+Wagtail provides a comprehensive and extensible search interface. In addition, it provides ways to promote search results through "Editor's Picks." Wagtail also collects simple statistics on queries made through the search interface.
+
+
+.. toctree::
+ :maxdepth: 2
+
+ for_python_developers
+ frontend_views
+ editors_picks
+ backends
diff --git a/docs/settings.rst b/docs/settings.rst
index 018af5d01..898fb3cb3 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -3,7 +3,7 @@
Configuring Django for Wagtail
==============================
-To install Wagtail completely from scratch, create a new Django project and an app within that project. For instructions on these tasks, see `Writing your first Django app`_. Your project directory will look like the following::
+To install Wagtail completely from scratch, create a new Django project and an app within that project. For instructions on these tasks, see `Writing your first Django app
`_. Your project directory will look like the following::
myproject/
myproject/
@@ -19,13 +19,7 @@ To install Wagtail completely from scratch, create a new Django project and an a
views.py
manage.py
-From your app directory, you can safely remove ``admin.py`` and ``views.py``, since Wagtail will provide this functionality for your models. Configuring Django to load Wagtail involves adding modules and variables to ``settings.py`` and urlconfs to ``urls.py``. For a more complete view of what's defined in these files, see `Django Settings`_ and `Django URL Dispatcher`_.
-
-.. _Writing your first Django app: https://docs.djangoproject.com/en/dev/intro/tutorial01/
-
-.. _Django Settings: https://docs.djangoproject.com/en/dev/topics/settings/
-
-.. _Django URL Dispatcher: https://docs.djangoproject.com/en/dev/topics/http/urls/
+From your app directory, you can safely remove ``admin.py`` and ``views.py``, since Wagtail will provide this functionality for your models. Configuring Django to load Wagtail involves adding modules and variables to ``settings.py`` and urlconfs to ``urls.py``. For a more complete view of what's defined in these files, see `Django Settings
`__ and `Django URL Dispatcher
`_.
What follows is a settings reference which skips many boilerplate Django settings. If you just want to get your Wagtail install up quickly without fussing with settings at the moment, see :ref:`complete_example_config`.
@@ -246,6 +240,16 @@ Email Notifications
Wagtail sends email notifications when content is submitted for moderation, and when the content is accepted or rejected. This setting lets you pick which email address these automatic notifications will come from. If omitted, Django will fall back to using the ``DEFAULT_FROM_EMAIL`` variable if set, and ``webmaster@localhost`` if not.
+Private Pages
+-------------
+
+.. code-block:: python
+
+ PASSWORD_REQUIRED_TEMPLATE = 'myapp/password_required.html'
+
+This is the path to the Django template which will be used to display the "password required" form when a user accesses a private page. For more details, see the :ref:`private_pages` documentation.
+
+
Other Django Settings Used by Wagtail
-------------------------------------
@@ -266,9 +270,7 @@ Other Django Settings Used by Wagtail
TEMPLATE_CONTEXT_PROCESSORS
USE_I18N
-For information on what these settings do, see `Django Settings`_.
-
-.. _Django Settings: https://docs.djangoproject.com/en/dev/ref/settings/
+For information on what these settings do, see `Django Settings
`__.
Search Signal Handlers
diff --git a/docs/sitemap_generation.rst b/docs/sitemap_generation.rst
new file mode 100644
index 000000000..203a423fb
--- /dev/null
+++ b/docs/sitemap_generation.rst
@@ -0,0 +1,60 @@
+.. _sitemap_generation:
+
+Sitemap generation
+==================
+
+This document describes how to create XML sitemaps for your Wagtail website using the ``wagtail.contrib.wagtailsitemaps`` module.
+
+
+Basic configuration
+~~~~~~~~~~~~~~~~~~~
+
+You firstly need to add ``"wagtail.contrib.wagtailsitemaps"`` to INSTALLED_APPS in your Django settings file:
+
+ .. code-block:: python
+
+ INSTALLED_APPS = [
+ ...
+
+ "wagtail.contrib.wagtailsitemaps",
+ ]
+
+
+Then, in urls.py, you need to add a link to the ``wagtail.contrib.wagtailsitemaps.views.sitemap`` view which generates the sitemap:
+
+.. code-block:: python
+
+ from wagtail.contrib.wagtailsitemaps.views import sitemap
+
+ urlpatterns = patterns('',
+ ...
+
+ url('^sitemap\.xml$', sitemap),
+ )
+
+
+You should now be able to browse to "/sitemap.xml" and see the sitemap working. By default, all published pages in your website will be added to the site map.
+
+
+Customising
+~~~~~~~~~~~
+
+URLs
+----
+
+The Page class defines a ``get_sitemap_urls`` method which you can override to customise sitemaps per page instance. This method must return a list of dictionaries, one dictionary per URL entry in the sitemap. You can exclude pages from the sitemap by returning an empty list.
+
+Each dictionary can contain the following:
+
+ - **location** (required) - This is the full URL path to add into the sitemap.
+ - **lastmod** - A python date or datetime set to when the page was last modified.
+ - **changefreq**
+ - **priority**
+
+You can add more but you will need to override the ``wagtailsitemaps/sitemap.xml`` template in order for them to be displayed in the sitemap.
+
+
+Cache
+-----
+
+By default, sitemaps are cached for 100 minutes. You can change this by setting ``WAGTAILSITEMAPS_CACHE_TIMEOUT`` in your Django settings to the number of seconds you would like the cache to last for.
diff --git a/docs/static_site_generation.rst b/docs/static_site_generation.rst
index a9379cd67..d6b598520 100644
--- a/docs/static_site_generation.rst
+++ b/docs/static_site_generation.rst
@@ -25,8 +25,19 @@ Then add ``django_medusa`` and ``wagtail.contrib.wagtailmedusa`` to ``INSTALLED_
]
+Rendering
+~~~~~~~~~
+
+To render a site, run ``./manage.py staticsitegen``. This will render the entire website and place the HTML in a folder called 'medusa_output'. The static and media folders need to be copied into this folder manually after the rendering is complete. This feature inherits django-medusa's ability to render your static site to Amazon S3 or Google App Engine; see the `medusa docs
`_ for configuration details.
+
+To test, open the 'medusa_output' folder in a terminal and run ``python -m SimpleHTTPServer``.
+
+
+Advanced topics
+~~~~~~~~~~~~~~~
+
Replacing GET parameters with custom routing
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+--------------------------------------------
Pages which require GET parameters (e.g. for pagination) don't generate suitable filenames for generated HTML files so they need to be changed to use custom routing instead.
@@ -34,6 +45,8 @@ For example, let's say we have a Blog Index which uses pagination. We can overri
.. code:: python
+ from wagtail.wagtailcore.url_routing import RouteResult
+
class BlogIndex(Page):
...
@@ -43,7 +56,7 @@ For example, let's say we have a Blog Index which uses pagination. We can overri
def route(self, request, path_components):
if self.live and len(path_components) == 2 and path_components[0] == 'page':
try:
- return self.serve(request, page=int(path_components[1]))
+ return RouteResult(self, kwargs={'page': int(path_components[1])})
except (TypeError, ValueError):
pass
@@ -51,7 +64,7 @@ For example, let's say we have a Blog Index which uses pagination. We can overri
Rendering pages which use custom routing
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+----------------------------------------
For page types that override the ``route`` method, we need to let django medusa know which URLs it responds on. This is done by overriding the ``get_static_site_paths`` method to make it yield one string per URL path.
@@ -72,12 +85,4 @@ For example, the BlogIndex above would need to yield one URL for each page of re
yield path
-Rendering
-~~~~~~~~~
-
-To render a site, run ``./manage.py staticsitegen``. This will render the entire website and place the HTML in a folder called 'medusa_output'. The static and media folders need to be copied into this folder manually after the rendering is complete. This feature inherits django-medusa's ability to render your static site to Amazon S3 or Google App Engine; see the `medusa docs `_ for configuration details.
-
-To test, open the 'medusa_output' folder in a terminal and run ``python -m SimpleHTTPServer``.
-
-
.. _django medusa: https://github.com/mtigas/django-medusa
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 4a9ff5c5e..b69c5d4e0 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,4 @@
# For coverage and PEP8 linting
coverage==3.7.1
-flake8==2.1.0
+flake8==2.2.1
mock==1.0.1
diff --git a/setup.py b/setup.py
index 7b26817cd..129a48468 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,8 @@
#!/usr/bin/env python
+import sys
+
+
try:
from setuptools import setup, find_packages
except ImportError:
@@ -16,9 +19,35 @@ except ImportError:
pass
+PY3 = sys.version_info[0] == 3
+
+
+install_requires = [
+ "Django>=1.6.2,<1.7",
+ "South>=0.8.4",
+ "django-compressor>=1.4",
+ "django-libsass>=0.2",
+ "django-modelcluster>=0.3",
+ "django-taggit==0.11.2",
+ "django-treebeard==2.0",
+ "Pillow>=2.3.0",
+ "beautifulsoup4>=4.3.2",
+ "lxml>=3.3.0",
+ "Unidecode>=0.04.14",
+ "six==1.7.3",
+ 'requests==2.3.0',
+]
+
+
+if not PY3:
+ install_requires += [
+ "unicodecsv>=0.9.4"
+ ]
+
+
setup(
name='wagtail',
- version='0.3.1',
+ version='0.4',
description='A Django content management system focused on flexibility and user experience',
author='Matthew Westcott',
author_email='matthew.westcott@torchbox.com',
@@ -37,23 +66,13 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.2',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
'Framework :: Django',
'Topic :: Internet :: WWW/HTTP :: Site Management',
],
- install_requires=[
- "Django>=1.6.2,<1.7",
- "South>=0.8.4",
- "django-compressor>=1.3",
- "django-libsass>=0.1",
- "django-modelcluster>=0.1",
- "django-taggit==0.11.2",
- "django-treebeard==2.0",
- "Pillow>=2.3.0",
- "beautifulsoup4>=4.3.2",
- "lxml>=3.3.0",
- 'unicodecsv>=0.9.4',
- 'Unidecode>=0.04.14',
- "BeautifulSoup==3.2.1", # django-compressor gets confused if we have lxml but not BS3 installed
- ],
+ install_requires=install_requires,
zip_safe=False,
)
diff --git a/tox.ini b/tox.ini
index 11b8bb3a2..2b17c65ba 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,15 +1,18 @@
[deps]
dj16=
Django>=1.6,<1.7
- pyelasticsearch==0.6.1
- elasticutils==0.8.2
+ elasticsearch==1.1.0
+ mock==1.0.1
[tox]
envlist =
py26-dj16-postgres,
py26-dj16-sqlite,
py27-dj16-postgres,
- py27-dj16-sqlite
+ py27-dj16-sqlite,
+ py32-dj16-postgres,
+ py33-dj16-postgres,
+ py34-dj16-postgres
# mysql not currently supported
# (wagtail.wagtailimages.tests.TestImageEditView currently fails with a
@@ -17,6 +20,12 @@ envlist =
# py26-dj16-mysql
# py27-dj16-mysql
+# South fails with sqlite on python3, because it tries to use DryRunMigrator which uses iteritems
+# py32-dj16-sqlite,
+# py33-dj16-sqlite,
+# py34-dj16-sqlite
+
+
[testenv]
commands=./runtests.py
@@ -24,7 +33,7 @@ commands=./runtests.py
basepython=python2.6
deps =
{[deps]dj16}
- psycopg2==2.5.2
+ psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
@@ -48,7 +57,7 @@ setenv =
basepython=python2.7
deps =
{[deps]dj16}
- psycopg2==2.5.2
+ psycopg2==2.5.3
setenv =
DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
@@ -67,3 +76,48 @@ deps =
setenv =
DATABASE_ENGINE=django.db.backends.mysql
DATABASE_USER=wagtail
+
+[testenv:py32-dj16-postgres]
+basepython=python3.2
+deps =
+ {[deps]dj16}
+ psycopg2==2.5.3
+setenv =
+ DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
+
+[testenv:py32-dj16-sqlite]
+basepython=python3.2
+deps =
+ {[deps]dj16}
+setenv =
+ DATABASE_ENGINE=django.db.backends.sqlite3
+
+[testenv:py33-dj16-postgres]
+basepython=python3.3
+deps =
+ {[deps]dj16}
+ psycopg2==2.5.3
+setenv =
+ DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
+
+[testenv:py33-dj16-sqlite]
+basepython=python3.3
+deps =
+ {[deps]dj16}
+setenv =
+ DATABASE_ENGINE=django.db.backends.sqlite3
+
+[testenv:py34-dj16-postgres]
+basepython=python3.4
+deps =
+ {[deps]dj16}
+ psycopg2==2.5.3
+setenv =
+ DATABASE_ENGINE=django.db.backends.postgresql_psycopg2
+
+[testenv:py34-dj16-sqlite]
+basepython=python3.4
+deps =
+ {[deps]dj16}
+setenv =
+ DATABASE_ENGINE=django.db.backends.sqlite3
diff --git a/wagtail/contrib/wagtailfrontendcache/__init__.py b/wagtail/contrib/wagtailfrontendcache/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/contrib/wagtailfrontendcache/models.py b/wagtail/contrib/wagtailfrontendcache/models.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/contrib/wagtailfrontendcache/signal_handlers.py b/wagtail/contrib/wagtailfrontendcache/signal_handlers.py
new file mode 100644
index 000000000..0fc92d46e
--- /dev/null
+++ b/wagtail/contrib/wagtailfrontendcache/signal_handlers.py
@@ -0,0 +1,25 @@
+from django.db import models
+from django.db.models.signals import post_delete
+
+from wagtail.wagtailcore.models import Page
+from wagtail.wagtailcore.signals import page_published
+
+from wagtail.contrib.wagtailfrontendcache.utils import purge_page_from_cache
+
+
+def page_published_signal_handler(instance, **kwargs):
+ purge_page_from_cache(instance)
+
+
+def post_delete_signal_handler(instance, **kwargs):
+ purge_page_from_cache(instance)
+
+
+def register_signal_handlers():
+ # Get list of models that are page types
+ indexed_models = [model for model in models.get_models() if issubclass(model, Page)]
+
+ # Loop through list and register signal handlers for each one
+ for model in indexed_models:
+ page_published.connect(page_published_signal_handler, sender=model)
+ post_delete.connect(post_delete_signal_handler, sender=model)
diff --git a/wagtail/contrib/wagtailfrontendcache/utils.py b/wagtail/contrib/wagtailfrontendcache/utils.py
new file mode 100644
index 000000000..66b77c971
--- /dev/null
+++ b/wagtail/contrib/wagtailfrontendcache/utils.py
@@ -0,0 +1,40 @@
+import requests
+from requests.adapters import HTTPAdapter
+
+from django.conf import settings
+
+
+class CustomHTTPAdapter(HTTPAdapter):
+ """
+ Requests will always send requests to whatever server is in the netloc
+ part of the URL. This is a problem with purging the cache as this netloc
+ may point to a different server (such as an nginx instance running in
+ front of the cache).
+
+ This class allows us to send a purge request directly to the cache server
+ with the host header still set correctly. It does this by changing the "url"
+ parameter of get_connection to always point to the cache server. Requests
+ will then use this connection to purge the page.
+ """
+ def __init__(self, cache_url):
+ self.cache_url = cache_url
+ super(CustomHTTPAdapter, self).__init__()
+
+ def get_connection(self, url, proxies=None):
+ return super(CustomHTTPAdapter, self).get_connection(self.cache_url, proxies)
+
+
+def purge_url_from_cache(url):
+ # Get session
+ cache_server_url = getattr(settings, 'WAGTAILFRONTENDCACHE_LOCATION', 'http://127.0.0.1:8000/')
+ session = requests.Session()
+ session.mount('http://', CustomHTTPAdapter(cache_server_url))
+
+ # Send purge request to cache
+ session.request('PURGE', url)
+
+
+def purge_page_from_cache(page):
+ # Purge cached paths from cache
+ for path in page.specific.get_cached_paths():
+ purge_url_from_cache(page.full_url + path[1:])
diff --git a/wagtail/contrib/wagtailsitemaps/__init__.py b/wagtail/contrib/wagtailsitemaps/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/contrib/wagtailsitemaps/models.py b/wagtail/contrib/wagtailsitemaps/models.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/wagtail/contrib/wagtailsitemaps/sitemap_generator.py b/wagtail/contrib/wagtailsitemaps/sitemap_generator.py
new file mode 100644
index 000000000..d22f88112
--- /dev/null
+++ b/wagtail/contrib/wagtailsitemaps/sitemap_generator.py
@@ -0,0 +1,21 @@
+from django.template.loader import render_to_string
+
+
+class Sitemap(object):
+ template = 'wagtailsitemaps/sitemap.xml'
+
+ def __init__(self, site):
+ self.site = site
+
+ def get_pages(self):
+ return self.site.root_page.get_descendants(inclusive=True).live().public().order_by('path')
+
+ def get_urls(self):
+ for page in self.get_pages():
+ for url in page.get_sitemap_urls():
+ yield url
+
+ def render(self):
+ return render_to_string(self.template, {
+ 'urlset': self.get_urls()
+ })
diff --git a/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml b/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml
new file mode 100644
index 000000000..30ca3c024
--- /dev/null
+++ b/wagtail/contrib/wagtailsitemaps/templates/wagtailsitemaps/sitemap.xml
@@ -0,0 +1,13 @@
+
+
+{% spaceless %}
+{% for url in urlset %}
+
+ {{ url.location }}
+ {% if url.lastmod %}{{ url.lastmod|date:"Y-m-d" }}{% endif %}
+ {% if url.changefreq %}{{ url.changefreq }}{% endif %}
+ {% if url.priority %}{{ url.priority }}{% endif %}
+
+{% endfor %}
+{% endspaceless %}
+
diff --git a/wagtail/contrib/wagtailsitemaps/tests.py b/wagtail/contrib/wagtailsitemaps/tests.py
new file mode 100644
index 000000000..a556ce156
--- /dev/null
+++ b/wagtail/contrib/wagtailsitemaps/tests.py
@@ -0,0 +1,94 @@
+from django.test import TestCase
+from django.core.cache import cache
+
+from wagtail.wagtailcore.models import Page, PageViewRestriction, Site
+from wagtail.tests.models import SimplePage
+
+from .sitemap_generator import Sitemap
+
+
+class TestSitemapGenerator(TestCase):
+ def setUp(self):
+ self.home_page = Page.objects.get(id=2)
+
+ self.child_page = self.home_page.add_child(instance=SimplePage(
+ title="Hello world!",
+ slug='hello-world',
+ live=True,
+ ))
+
+ self.unpublished_child_page = self.home_page.add_child(instance=SimplePage(
+ title="Unpublished",
+ slug='unpublished',
+ live=False,
+ ))
+
+ self.protected_child_page = self.home_page.add_child(instance=SimplePage(
+ title="Protected",
+ slug='protected',
+ live=True,
+ ))
+ PageViewRestriction.objects.create(page=self.protected_child_page, password='hello')
+
+ self.site = Site.objects.get(is_default_site=True)
+
+ def test_get_pages(self):
+ sitemap = Sitemap(self.site)
+ pages = sitemap.get_pages()
+
+ self.assertIn(self.child_page.page_ptr, pages)
+ self.assertNotIn(self.unpublished_child_page.page_ptr, pages)
+ self.assertNotIn(self.protected_child_page.page_ptr, pages)
+
+ def test_get_urls(self):
+ sitemap = Sitemap(self.site)
+ urls = [url['location'] for url in sitemap.get_urls()]
+
+ self.assertIn('http://localhost/', urls) # Homepage
+ self.assertIn('http://localhost/hello-world/', urls) # Child page
+
+ def test_render(self):
+ sitemap = Sitemap(self.site)
+ xml = sitemap.render()
+
+ # Check that a URL has made it into the xml
+ self.assertIn('http://localhost/hello-world/', xml)
+
+ # Make sure the unpublished page didn't make it into the xml
+ self.assertNotIn('http://localhost/unpublished/', xml)
+
+ # Make sure the protected page didn't make it into the xml
+ self.assertNotIn('http://localhost/protected/', xml)
+
+
+class TestSitemapView(TestCase):
+ def test_sitemap_view(self):
+ response = self.client.get('/sitemap.xml')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, 'wagtailsitemaps/sitemap.xml')
+ self.assertEqual(response['Content-Type'], 'text/xml; charset=utf-8')
+
+ def test_sitemap_view_cache(self):
+ cache_key = 'wagtail-sitemap:%d' % Site.objects.get(is_default_site=True).id
+
+ # Check that the key is not in the cache
+ self.assertFalse(cache.has_key(cache_key))
+
+ # Hit the view
+ first_response = self.client.get('/sitemap.xml')
+
+ self.assertEqual(first_response.status_code, 200)
+ self.assertTemplateUsed(first_response, 'wagtailsitemaps/sitemap.xml')
+
+ # Check that the key is in the cache
+ self.assertTrue(cache.has_key(cache_key))
+
+ # Hit the view again. Should come from the cache this time
+ second_response = self.client.get('/sitemap.xml')
+
+ self.assertEqual(second_response.status_code, 200)
+ self.assertTemplateNotUsed(second_response, 'wagtailsitemaps/sitemap.xml') # Sitemap should not be re rendered
+
+ # Check that the content is the same
+ self.assertEqual(first_response.content, second_response.content)
diff --git a/wagtail/contrib/wagtailsitemaps/views.py b/wagtail/contrib/wagtailsitemaps/views.py
new file mode 100644
index 000000000..04f31fdae
--- /dev/null
+++ b/wagtail/contrib/wagtailsitemaps/views.py
@@ -0,0 +1,23 @@
+from django.http import HttpResponse
+from django.core.cache import cache
+from django.conf import settings
+
+from .sitemap_generator import Sitemap
+
+
+def sitemap(request):
+ cache_key = 'wagtail-sitemap:' + str(request.site.id)
+ sitemap_xml = cache.get(cache_key)
+
+ if not sitemap_xml:
+ # Rerender sitemap
+ sitemap = Sitemap(request.site)
+ sitemap_xml = sitemap.render()
+
+ cache.set(cache_key, sitemap_xml, getattr(settings, 'WAGTAILSITEMAPS_CACHE_TIMEOUT', 6000))
+
+ # Build response
+ response = HttpResponse(sitemap_xml)
+ response['Content-Type'] = "text/xml; charset=utf-8"
+
+ return response
diff --git a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
index 54cfd23a6..8506e9075 100644
--- a/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
+++ b/wagtail/contrib/wagtailstyleguide/templates/wagtailstyleguide/base.html
@@ -42,9 +42,10 @@
color-teal
color-teal-darker
color-teal-dark
- color-red
- color-orange
- color-green
+
+
+ - color-salmon
+ - color-salmon-light
- color-grey-1
@@ -54,6 +55,12 @@
- color-grey-4
- color-grey-5
+
+ - color-red
+ - color-orange
+ - color-green
+
+
@@ -407,6 +414,7 @@
warning
success
date
+ time
form
diff --git a/wagtail/contrib/wagtailstyleguide/views.py b/wagtail/contrib/wagtailstyleguide/views.py
index 52c5433a1..9683289d2 100644
--- a/wagtail/contrib/wagtailstyleguide/views.py
+++ b/wagtail/contrib/wagtailstyleguide/views.py
@@ -1,16 +1,10 @@
from django import forms
-from django.db import models
from django.shortcuts import render
from django.utils.translation import ugettext as _
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
-from wagtail.wagtailadmin.edit_handlers import PageChooserPanel
-from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
-from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel
-
from wagtail.wagtailadmin.forms import SearchForm
-from wagtail.wagtailcore.fields import RichTextField
CHOICES = (
@@ -23,6 +17,7 @@ class ExampleForm(forms.Form):
url = forms.URLField(required=True)
email = forms.EmailField(max_length=254)
date = forms.DateField()
+ time = forms.TimeField()
select = forms.ChoiceField(choices=CHOICES)
boolean = forms.BooleanField(required=False)
diff --git a/wagtail/contrib/wagtailstyleguide/wagtail_hooks.py b/wagtail/contrib/wagtailstyleguide/wagtail_hooks.py
index ae2989be9..56a17fc16 100644
--- a/wagtail/contrib/wagtailstyleguide/wagtail_hooks.py
+++ b/wagtail/contrib/wagtailstyleguide/wagtail_hooks.py
@@ -1,14 +1,10 @@
-from django.conf import settings
-from django.conf.urls import include, url
+from django.conf.urls import url
from django.core import urlresolvers
-from django.utils.html import format_html, format_html_join
from django.utils.translation import ugettext_lazy as _
-from wagtail.wagtailadmin import hooks
+from wagtail.wagtailcore import hooks
from wagtail.wagtailadmin.menu import MenuItem
-from wagtail.wagtailimages import urls
-
from . import views
diff --git a/wagtail/tests/fixtures/test.json b/wagtail/tests/fixtures/test.json
index 3f5f57c63..f31610279 100644
--- a/wagtail/tests/fixtures/test.json
+++ b/wagtail/tests/fixtures/test.json
@@ -23,7 +23,7 @@
"model": "wagtailcore.page",
"fields": {
"title": "Welcome to the Wagtail test site!",
- "numchild": 3,
+ "numchild": 5,
"show_in_menus": false,
"live": true,
"depth": 2,
@@ -85,6 +85,17 @@
}
},
+{
+ "pk": 1,
+ "model": "tests.eventpagespeaker",
+ "fields": {
+ "page": 4,
+ "first_name": "Santa",
+ "last_name": "Claus",
+ "sort_order": 0
+ }
+},
+
{
"pk": 5,
"model": "wagtailcore.page",
@@ -246,6 +257,79 @@
}
},
+{
+ "pk": 10,
+ "model": "wagtailcore.page",
+ "fields": {
+ "title": "Old style route method",
+ "numchild": 0,
+ "show_in_menus": true,
+ "live": true,
+ "depth": 3,
+ "content_type": ["tests", "pagewitholdstyleroutemethod"],
+ "path": "000100010004",
+ "url_path": "/home/old-style-route/",
+ "slug": "old-style-route"
+ }
+},
+{
+ "pk": 10,
+ "model": "tests.pagewitholdstyleroutemethod",
+ "fields": {
+ "content": "Test with old style route method
"
+ }
+},
+
+{
+ "pk": 11,
+ "model": "wagtailcore.page",
+ "fields": {
+ "title": "Secret plans",
+ "numchild": 1,
+ "show_in_menus": true,
+ "live": true,
+ "depth": 3,
+ "content_type": ["tests", "simplepage"],
+ "path": "000100010005",
+ "url_path": "/home/secret-plans/",
+ "slug": "secret-plans"
+ }
+},
+{
+ "pk": 11,
+ "model": "tests.simplepage",
+ "fields": {
+ "content": "muahahahaha
"
+ }
+},
+
+{
+ "pk": 12,
+ "model": "wagtailcore.page",
+ "fields": {
+ "title": "Steal underpants",
+ "numchild": 0,
+ "show_in_menus": true,
+ "live": true,
+ "depth": 4,
+ "content_type": ["tests", "eventpage"],
+ "path": "0001000100050001",
+ "url_path": "/home/secret-plans/steal-underpants/",
+ "slug": "steal-underpants"
+ }
+},
+{
+ "pk": 12,
+ "model": "tests.eventpage",
+ "fields": {
+ "date_from": "2015-07-04",
+ "audience": "private",
+ "location": "Marks and Spencer",
+ "body": "meet in the menswear department at noon
",
+ "cost": "free"
+ }
+},
+
{
"pk": 1,
"model": "wagtailcore.site",
@@ -478,6 +562,14 @@
"submit_time": "2014-01-01T12:00:00.000Z"
}
},
+{
+ "pk": 1,
+ "model": "tests.advert",
+ "fields": {
+ "text": "test_advert",
+ "url": "http://www.example.com"
+ }
+},
{
"pk": 1,
"model": "wagtaildocs.Document",
@@ -495,5 +587,14 @@
"width": 0,
"height": 0
}
+},
+
+{
+ "pk": 1,
+ "model": "wagtailcore.pageviewrestriction",
+ "fields": {
+ "page": 11,
+ "password": "swordfish"
+ }
}
]
diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py
index ca6d411da..2f151c465 100644
--- a/wagtail/tests/models.py
+++ b/wagtail/tests/models.py
@@ -1,6 +1,9 @@
from django.db import models
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
+from django.utils.encoding import python_2_unicode_compatible
+
from modelcluster.fields import ParentalKey
+
from wagtail.wagtailcore.models import Page, Orderable
from wagtail.wagtailcore.fields import RichTextField
from wagtail.wagtailadmin.edit_handlers import FieldPanel, MultiFieldPanel, InlinePanel, PageChooserPanel
@@ -8,6 +11,7 @@ from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel
from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormField
from wagtail.wagtailsnippets.models import register_snippet
+from wagtail.wagtailsearch import indexed
EVENT_AUDIENCE_CHOICES = (
@@ -103,6 +107,19 @@ class SimplePage(Page):
content = models.TextField()
+class PageWithOldStyleRouteMethod(Page):
+ """
+ Prior to Wagtail 0.4, the route() method on Page returned an HttpResponse
+ rather than a Page instance. As subclasses of Page may override route,
+ we need to continue accepting this convention (albeit as a deprecated API).
+ """
+ content = models.TextField()
+ template = 'tests/simple_page.html'
+
+ def route(self, request, path_components):
+ return self.serve(request)
+
+
# Event page
class EventPageCarouselItem(Orderable, CarouselItem):
@@ -163,6 +180,8 @@ class EventPage(Page):
indexed_fields = ('get_audience_display', 'location', 'body')
search_name = "Event"
+ password_required_template = 'tests/event_page_password_required.html'
+
EventPage.content_panels = [
FieldPanel('title', classname="full title"),
FieldPanel('date_from'),
@@ -259,6 +278,7 @@ FormPage.content_panels = [
# Snippets
+@python_2_unicode_compatible
class Advert(models.Model):
url = models.URLField(null=True, blank=True)
text = models.CharField(max_length=255)
@@ -268,7 +288,7 @@ class Advert(models.Model):
FieldPanel('text'),
]
- def __unicode__(self):
+ def __str__(self):
return self.text
@@ -281,18 +301,20 @@ register_snippet(Advert)
# to ensure specific [in]correct register ordering
# AlphaSnippet is registered during TestSnippetOrdering
+@python_2_unicode_compatible
class AlphaSnippet(models.Model):
text = models.CharField(max_length=255)
- def __unicode__(self):
+ def __str__(self):
return self.text
# ZuluSnippet is registered during TestSnippetOrdering
+@python_2_unicode_compatible
class ZuluSnippet(models.Model):
text = models.CharField(max_length=255)
- def __unicode__(self):
+ def __str__(self):
return self.text
@@ -310,3 +332,60 @@ class BusinessSubIndex(Page):
class BusinessChild(Page):
subpage_types = []
+
+
+class SearchTest(models.Model, indexed.Indexed):
+ title = models.CharField(max_length=255)
+ content = models.TextField()
+ live = models.BooleanField(default=False)
+ published_date = models.DateField(null=True)
+
+ search_fields = [
+ indexed.SearchField('title', partial_match=True),
+ indexed.SearchField('content'),
+ indexed.SearchField('callable_indexed_field'),
+ indexed.FilterField('title'),
+ indexed.FilterField('live'),
+ indexed.FilterField('published_date'),
+ ]
+
+ def callable_indexed_field(self):
+ return "Callable"
+
+
+class SearchTestChild(SearchTest):
+ subtitle = models.CharField(max_length=255, null=True, blank=True)
+ extra_content = models.TextField()
+
+ search_fields = SearchTest.search_fields + [
+ indexed.SearchField('subtitle', partial_match=True),
+ indexed.SearchField('extra_content'),
+ ]
+
+
+class SearchTestOldConfig(models.Model, indexed.Indexed):
+ """
+ This tests that the Indexed class can correctly handle models that
+ use the old "indexed_fields" configuration format.
+ """
+ indexed_fields = {
+ # A search field with predictive search and boosting
+ 'title': {
+ 'type': 'string',
+ 'analyzer': 'edgengram_analyzer',
+ 'boost': 100,
+ },
+
+ # A filter field
+ 'live': {
+ 'type': 'boolean',
+ 'index': 'not_analyzed',
+ },
+ }
+
+class SearchTestOldConfigList(models.Model, indexed.Indexed):
+ """
+ This tests that the Indexed class can correctly handle models that
+ use the old "indexed_fields" configuration format using a list.
+ """
+ indexed_fields = ['title', 'content']
diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py
index 5cee3d6d8..103e19b99 100644
--- a/wagtail/tests/settings.py
+++ b/wagtail/tests/settings.py
@@ -28,9 +28,8 @@ STATICFILES_FINDERS = (
'compressor.finders.CompressorFinder',
)
-MEDIA_ROOT=MEDIA_ROOT
-
USE_TZ = True
+TIME_ZONE = 'UTC'
TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + (
'django.core.context_processors.request',
@@ -72,6 +71,7 @@ INSTALLED_APPS = [
'wagtail.wagtailredirects',
'wagtail.wagtailforms',
'wagtail.contrib.wagtailstyleguide',
+ 'wagtail.contrib.wagtailsitemaps',
'wagtail.tests',
]
diff --git a/wagtail/tests/templates/tests/event_page_password_required.html b/wagtail/tests/templates/tests/event_page_password_required.html
new file mode 100644
index 000000000..e58d740ad
--- /dev/null
+++ b/wagtail/tests/templates/tests/event_page_password_required.html
@@ -0,0 +1,15 @@
+
+
+
+ {{ self.title }}
+
+
+ {{ self.title }}
+ This event is invitation only. Please enter your password to see the details.
+
+
+
diff --git a/wagtail/tests/urls.py b/wagtail/tests/urls.py
index d04c871a3..c9ca98bba 100644
--- a/wagtail/tests/urls.py
+++ b/wagtail/tests/urls.py
@@ -3,18 +3,17 @@ from django.conf.urls import patterns, include, url
from wagtail.wagtailcore import urls as wagtail_urls
from wagtail.wagtailadmin import urls as wagtailadmin_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls
-from wagtail.wagtailsearch.urls import frontend as wagtailsearch_frontend_urls
-
-# Signal handlers
-from wagtail.wagtailsearch import register_signal_handlers as wagtailsearch_register_signal_handlers
-wagtailsearch_register_signal_handlers()
+from wagtail.wagtailsearch import urls as wagtailsearch_urls
+from wagtail.contrib.wagtailsitemaps.views import sitemap
urlpatterns = patterns('',
url(r'^admin/', include(wagtailadmin_urls)),
- url(r'^search/', include(wagtailsearch_frontend_urls)),
+ url(r'^search/', include(wagtailsearch_urls)),
url(r'^documents/', include(wagtaildocs_urls)),
+ url(r'^sitemap\.xml$', sitemap),
+
# For anything not caught by a more specific rule above, hand over to
# Wagtail's serving mechanism
url(r'', include(wagtail_urls)),
diff --git a/wagtail/tests/utils.py b/wagtail/tests/utils.py
index 02327799b..2e95d0210 100644
--- a/wagtail/tests/utils.py
+++ b/wagtail/tests/utils.py
@@ -1,7 +1,8 @@
-from django.test import TestCase
+from contextlib import contextmanager
+import warnings
+
from django.contrib.auth.models import User
-from django.utils.six.moves.urllib.parse import urlparse, ParseResult
-from django.http import QueryDict
+from django.utils import six
# We need to make sure that we're using the same unittest library that Django uses internally
# Otherwise, we get issues with the "SkipTest" and "ExpectedFailure" exceptions being recognised as errors
@@ -24,3 +25,17 @@ class WagtailTestUtils(object):
self.client.login(username='test', password='password')
return user
+
+ def assertRegex(self, *args, **kwargs):
+ six.assertRegex(self, *args, **kwargs)
+
+ @staticmethod
+ @contextmanager
+ def ignore_deprecation_warnings():
+ with warnings.catch_warnings(record=True) as warning_list: # catch all warnings
+ yield
+
+ # rethrow all warnings that were not DeprecationWarnings
+ for w in warning_list:
+ if not issubclass(w.category, DeprecationWarning):
+ warnings.showwarning(message=w.message, category=w.category, filename=w.filename, lineno=w.lineno, file=w.file, line=w.line)
diff --git a/wagtail/tests/wagtail_hooks.py b/wagtail/tests/wagtail_hooks.py
index 5dccca666..8c398ed2c 100644
--- a/wagtail/tests/wagtail_hooks.py
+++ b/wagtail/tests/wagtail_hooks.py
@@ -1,4 +1,6 @@
-from wagtail.wagtailadmin import hooks
+from django.http import HttpResponse
+
+from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.whitelist import attribute_rule, check_url, allow_without_attributes
def editor_css():
@@ -17,3 +19,9 @@ def whitelister_element_rules():
'a': attribute_rule({'href': check_url, 'target': True}),
}
hooks.register('construct_whitelister_element_rules', whitelister_element_rules)
+
+
+def block_googlebot(page, request, serve_args, serve_kwargs):
+ if request.META.get('HTTP_USER_AGENT') == 'GoogleBot':
+ return HttpResponse("bad googlebot no cookie
")
+hooks.register('before_serve_page', block_googlebot)
diff --git a/wagtail/wagtailadmin/edit_handlers.py b/wagtail/wagtailadmin/edit_handlers.py
index a15440016..d2eb181c3 100644
--- a/wagtail/wagtailadmin/edit_handlers.py
+++ b/wagtail/wagtailadmin/edit_handlers.py
@@ -1,6 +1,9 @@
+from __future__ import unicode_literals
+
import copy
-import re
-import datetime
+
+from six import string_types
+from six import text_type
from taggit.forms import TagWidget
from modelcluster.forms import ClusterForm, ClusterFormMetaclass
@@ -9,13 +12,10 @@ from django.template.loader import render_to_string
from django.template.defaultfilters import addslashes
from django.utils.safestring import mark_safe
from django import forms
-from django.db import models
from django.forms.models import fields_for_model
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured, ValidationError
+from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured
from django.core.urlresolvers import reverse
-from django.conf import settings
-from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy
from wagtail.wagtailcore.models import Page
@@ -72,7 +72,7 @@ class WagtailAdminModelFormMetaclass(ClusterFormMetaclass):
new_class = super(WagtailAdminModelFormMetaclass, cls).__new__(cls, name, bases, attrs)
return new_class
-WagtailAdminModelForm = WagtailAdminModelFormMetaclass('WagtailAdminModelForm', (ClusterForm,), {})
+WagtailAdminModelForm = WagtailAdminModelFormMetaclass(str('WagtailAdminModelForm'), (ClusterForm,), {})
# Now, any model forms built off WagtailAdminModelForm instead of ModelForm should pick up
# the nice form fields defined in FORM_FIELD_OVERRIDES.
@@ -108,7 +108,7 @@ def get_form_for_model(
# Give this new form class a reasonable name.
class_name = model.__name__ + str('Form')
form_class_attrs = {
- 'Meta': type('Meta', (object,), attrs)
+ 'Meta': type(str('Meta'), (object,), attrs)
}
return WagtailAdminModelFormMetaclass(class_name, (WagtailAdminModelForm,), form_class_attrs)
@@ -140,6 +140,10 @@ def extract_panel_definitions_from_model_class(model, exclude=None):
return panels
+def set_page_edit_handler(page_class, handlers):
+ page_class.handlers = handlers
+
+
class EditHandler(object):
"""
Abstract class providing sensible default behaviours for objects implementing
@@ -183,19 +187,21 @@ class EditHandler(object):
heading = ""
help_text = ""
- def object_classnames(self):
+ def classes(self):
"""
- Additional classnames to add to the when rendering this
- within an ObjectList
+ Additional CSS classnames to add to whatever kind of object this is at output.
+ Subclasses of EditHandler should override this, invoking super(B, self).classes() to
+ append more classes specific to the situation.
"""
- return ""
- def field_classnames(self):
- """
- Additional classnames to add to the when rendering this within a
-