Merge branch 'master' into 146-display-calculated-usage

This commit is contained in:
Tom Talbot 2014-07-08 18:17:22 +01:00
commit 0ebdce52ee
64 changed files with 1883 additions and 240 deletions

View file

@ -55,7 +55,8 @@ Contributing
~~~~~~~~~~~~
If you're a Python or Django developer, fork the repo and get stuck in!
We suggest you start by checking the `Help develop me! <https://github.com/torchbox/wagtail/issues?labels=Help+develop+me%21>`_ label.
We suggest you start by checking the `Help develop me! <https://github.com/torchbox/wagtail/issues?labels=Help+develop+me%21>`_ label and the `coding guidelines <http://wagtail.readthedocs.org/en/latest/contributing.html#coding-guidelines>`_.
Send us a useful pull request and we'll post you a `t-shirt <https://twitter.com/WagtailCMS/status/432166799464210432/photo/1>`_.
We also welcome `translations <http://wagtail.readthedocs.org/en/latest/contributing.html#translations>`_ for Wagtail's interface.

View file

@ -171,15 +171,18 @@ Other Relationships
Your ``Page``-derived models might have other interrelationships which extend the basic Wagtail tree or depart from it entirely. You could provide functions to navigate between siblings, such as a "Next Post" link on a blog page (``post->post->post``). It might make sense for subtrees to interrelate, such as in a discussion forum (``forum->post->replies``) Skipping across the hierarchy might make sense, too, as all objects of a certain model class might interrelate regardless of their ancestors (``events = EventPage.objects.all``). It's largely up to the models to define their interrelations, the possibilities are really endless.
.. _anatomy_of_a_wagtail_request:
Anatomy of a Wagtail Request
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
For going beyond the basics of model definition and interrelation, it might help to know how Wagtail handles requests and constructs responses. In short, it goes something like:
#. Django gets a request and routes through Wagtail's URL dispatcher definitions
#. Starting from the root content piece, Wagtail traverses the page tree, letting the model for each piece of content along the path decide how to ``route()`` the next step in the path.
#. A model class decides that routing is done and it's now time to ``serve()`` content.
#. ``serve()`` constructs a context using ``get_context()``
#. Wagtail checks the hostname of the request to determine which ``Site`` record will handle this request.
#. Starting from the root page of that site, Wagtail traverses the page tree, calling the ``route()`` method and letting each page model decide whether it will handle the request itself or pass it on to a child page.
#. The page responsible for handling the request returns a ``RouteResult`` object from ``route()``, which identifies the page along with any additional args/kwargs to be passed to ``serve()``.
#. Wagtail calls ``serve()``, which constructs a context using ``get_context()``
#. ``serve()`` finds a template to pass it to using ``get_template()``
#. A response object is returned by ``serve()`` and Django responds to the requester.

View file

@ -51,7 +51,7 @@ source_suffix = '.rst'
master_doc = 'index'
# General information about the project.
project = u'Wagtail Documentation'
project = u'Wagtail'
copyright = u'2014, Torchbox'
# The version info for the project you're documenting, acts as replacement for
@ -59,9 +59,9 @@ copyright = u'2014, Torchbox'
# built documents.
#
# The short X.Y version.
version = '0.1'
version = '0.3.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
release = '0.3.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.

View file

@ -15,6 +15,7 @@ Coding guidelines
~~~~~~~~~~~~~~~~~
* PEP8. We ask that all Python contributions adhere to the `PEP8 <http://www.python.org/dev/peps/pep-0008/>`_ style guide, apart from the restriction on line length (E501). The `pep8 tool <http://pep8.readthedocs.org/en/latest/>`_ makes it easy to check your code, e.g. ``pep8 --ignore=E501 your_file.py``.
* Python 2 and 3 compatibility. All contributions should support Python 2 and 3 and we recommend using the `six <https://pythonhosted.org/six/>`_ compatibility library (use the pip version installed as a dependency, not the version bundled with Django).
* Tests. Wagtail has a suite of tests, which we are committed to improving and expanding. We run continuous integration at `travis-ci.org/torchbox/wagtail <https://travis-ci.org/torchbox/wagtail>`_ to ensure that no commits or pull requests introduce test failures. If your contributions add functionality to Wagtail, please include the additional tests to cover it; if your contributions alter existing functionality, please update the relevant tests accordingly.
Styleguide

View file

@ -402,6 +402,23 @@ Registering functions with a Wagtail hook follows the following pattern:
Where ``'hook'`` is one of the following hook strings and ``function`` is a function you've defined to handle the hook.
.. _before_serve_page:
``before_serve_page``
.. versionadded:: 0.4
Called when Wagtail is about to serve a page. The callable passed into the hook will receive the page object, the request object, and the args and kwargs that will be passed to the page's ``serve()`` method. If the callable returns an ``HttpResponse``, that response will be returned immediately to the user, and Wagtail will not proceed to call ``serve()`` on the page.
.. code-block:: python
from wagtail.wagtailcore import hooks
def block_googlebot(page, request, serve_args, serve_kwargs):
if request.META.get('HTTP_USER_AGENT') == 'GoogleBot':
return HttpResponse("<h1>bad googlebot no cookie</h1>")
hooks.register('before_serve_page', block_googlebot)
.. _construct_wagtail_edit_bird:
``construct_wagtail_edit_bird``

View file

@ -19,6 +19,7 @@ It supports Django 1.6.2+ on Python 2.6, 2.7, 3.2, 3.3 and 3.4. Django 1.7 suppo
advanced_topics
deploying
performance
private_pages
static_site_generation
frontend_cache_purging
sitemap_generation

View file

@ -1,6 +1,11 @@
.. _management_commands:
Management commands
===================
.. _publish_scheduled_pages:
publish_scheduled_pages
-----------------------
@ -8,6 +13,9 @@ publish_scheduled_pages
This command publishes or unpublishes pages that have had these actions scheduled by an editor. It is recommended to run this command once an hour.
.. _fixtree:
fixtree
-------
@ -15,6 +23,9 @@ fixtree
This command scans for errors in your database and attempts to fix any issues it finds.
.. _move_pages:
move_pages
----------
@ -30,6 +41,9 @@ Options:
- **to**
This is the **id** of the page to move pages to.
.. _update_index:
update_index
------------
@ -44,6 +58,9 @@ It is recommended to run this command once a week and at the following times:
The search may not return any results while this command is running, so avoid running it at peak times.
.. _search_garbage_collect:
search_garbage_collect
----------------------

View file

@ -58,8 +58,8 @@ Wagtail routes requests by iterating over the path components (separated with a
# find a matching child or 404
try:
subpage = self.get_children().get(slug=child_slug)
except Page.DoesNotExist:
raise Http404
except Page.DoesNotExist:
raise Http404
# delegate further routing
return subpage.specific.route(request, remaining_components)
@ -67,13 +67,16 @@ Wagtail routes requests by iterating over the path components (separated with a
else:
# request is for this very page
if self.live:
# use the serve() method to render the request if the page is published
return self.serve(request)
# Return a RouteResult that will tell Wagtail to call
# this page's serve() method
return RouteResult(self)
else:
# the page matches the request, but isn't published, so 404
raise Http404
The contract is pretty simple. ``route()`` takes the current object (``self``), the ``request`` object, and a list of the remaining ``path_components`` from the request URL. It either continues delegating routing by calling ``route()`` again on one of its children in the Wagtail tree, or ends the routing process by serving something -- either normally through the ``self.serve()`` method or by raising a 404 error.
``route()`` takes the current object (``self``), the ``request`` object, and a list of the remaining ``path_components`` from the request URL. It either continues delegating routing by calling ``route()`` again on one of its children in the Wagtail tree, or ends the routing process by returning a ``RouteResult`` object or raising a 404 error.
The ``RouteResult`` object (defined in wagtail.wagtailcore.url_routing) encapsulates all the information Wagtail needs to call a page's ``serve()`` method and return a final response: this information consists of the page object, and any additional args / kwargs to be passed to ``serve()``.
By overriding the ``route()`` method, we could create custom endpoints for each object in the Wagtail tree. One use case might be using an alternate template when encountering the ``print/`` endpoint in the path. Another might be a REST API which interacts with the current object. Just to see what's involved, lets make a simple model which prints out all of its child path components.
@ -82,6 +85,7 @@ First, ``models.py``:
.. code-block:: python
from django.shortcuts import render
from wagtail.wagtailcore.url_routing import RouteResult
...
@ -89,15 +93,20 @@ First, ``models.py``:
def route(self, request, path_components):
if path_components:
return render(request, self.template, {
'self': self,
'echo': ' '.join(path_components),
})
# tell Wagtail to call self.serve() with an additional 'path_components' kwarg
return RouteResult(self, kwargs={'path_components': path_components})
else:
if self.live:
return self.serve(request)
else:
raise Http404
# tell Wagtail to call self.serve() with no further args
return RouteResult(self)
else:
raise Http404
def serve(self, path_components=[]):
render(request, self.template, {
'self': self,
'echo': ' '.join(path_components),
})
Echoer.content_panels = [
FieldPanel('title', classname="full title"),
@ -107,7 +116,7 @@ First, ``models.py``:
MultiFieldPanel(COMMON_PANELS, "Common page configuration"),
]
This model, ``Echoer``, doesn't define any properties, but does subclass ``Page`` so objects will be able to have a custom title and slug. The template just has to display our ``{{ echo }}`` property. We're skipping the ``serve()`` method entirely, but you could include your render code there to stay consistent with Wagtail's conventions.
This model, ``Echoer``, doesn't define any properties, but does subclass ``Page`` so objects will be able to have a custom title and slug. The template just has to display our ``{{ echo }}`` property.
Now, once creating a new ``Echoer`` page in the Wagtail admin titled "Echo Base," requests such as::
@ -117,6 +126,12 @@ Will return::
tauntaun kennel bed and breakfast
Be careful if you're introducing new required arguments to the ``serve()`` method - Wagtail still needs to be able to display a default view of the page for previewing and moderation, and by default will attempt to do this by calling ``serve()`` with a request object and no further arguments. If your ``serve()`` method does not accept that as a method signature, you will need to override the page's ``serve_preview()`` method to call ``serve()`` with suitable arguments:
.. code-block:: python
def serve_preview(self, request, mode_name):
return self.serve(request, color='purple')
.. _tagging:

View file

@ -1,7 +1,7 @@
Performance
===========
Wagtail is designed for speed, both in the editor interface and on the front-end, but if you want even better performance or you need to handle very high volumes of traffic, here are some tips on eeking out the most from your installation.
Wagtail is designed for speed, both in the editor interface and on the front-end, but if you want even better performance or you need to handle very high volumes of traffic, here are some tips on eking out the most from your installation.
Editor interface
~~~~~~~~~~~~~~~~
@ -41,4 +41,4 @@ Public users
Caching proxy
-------------
To support high volumes of traffic with excellent response times, we recommend a caching proxy. Both `Varnish <http://www.varnish-cache.org/>`_ and `Squid <http://www.squid-cache.org/>`_ have been tested in production. Hosted proxies like `Cloudflare <https://www.cloudflare.com/>`_ should also work well.
To support high volumes of traffic with excellent response times, we recommend a caching proxy. Both `Varnish <http://www.varnish-cache.org/>`_ and `Squid <http://www.squid-cache.org/>`_ have been tested in production. Hosted proxies like `Cloudflare <https://www.cloudflare.com/>`_ should also work well.

64
docs/private_pages.rst Normal file
View file

@ -0,0 +1,64 @@
.. _private_pages:
Private pages
=============
Users with publish permission on a page can set it to be private by clicking the 'Privacy' control in the top right corner of the page explorer or editing interface, and setting a password. Users visiting this page, or any of its subpages, will be prompted to enter a password before they can view the page.
Private pages work on Wagtail out of the box - the site implementer does not need to do anything to set them up. However, the default "password required" form is only a bare-bones HTML page, and site implementers may wish to replace this with a page customised to their site design.
Setting up a global "password required" page
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
By setting ``PASSWORD_REQUIRED_TEMPLATE`` in your Django settings file, you can specify the path of a template which will be used for all "password required" forms on the site (except for page types that specifically override it - see below):
.. code-block:: python
PASSWORD_REQUIRED_TEMPLATE = 'myapp/password_required.html'
This template will receive the same set of context variables that the blocked page would pass to its own template via ``get_context()`` - including ``self`` to refer to the page object itself - plus the following additional variables (which override any of the page's own context variables of the same name):
- **form** - A Django form object for the password prompt; this will contain a field named ``password`` as its only visible field. A number of hidden fields may also be present, so the page must loop over ``form.hidden_fields`` if not using one of Django's rendering helpers such as ``form.as_p``.
- **action_url** - The URL that the password form should be submitted to, as a POST request.
A basic template suitable for use as PASSWORD_REQUIRED_TEMPLATE might look like this:
.. code-block:: django
<!DOCTYPE HTML>
<html>
<head>
<title>Password required</title>
</head>
<body>
<h1>Password required</h1>
<p>You need a password to access this page.</p>
<form action="{{ action_url }}" method="POST">
{% csrf_token %}
{{ form.non_field_errors }}
<div>
{{ form.password.errors }}
{{ form.password.label_tag }}
{{ form.password }}
</div>
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<input type="submit" value="Continue" />
</form>
</body>
</html>
Setting a "password required" page for a specific page type
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The attribute ``password_required_template`` can be defined on a page model to use a custom template for the "password required" view, for that page type only. For example, if a site had a page type for displaying embedded videos along with a description, it might choose to use a custom "password required" template that displays the video description as usual, but shows the password form in place of the video embed.
.. code-block:: python
class VideoPage(Page):
...
password_required_template = 'video/password_required.html'

175
docs/releases/0.4.rst Normal file
View file

@ -0,0 +1,175 @@
=========================
Wagtail 0.4 release notes
=========================
Whats new
=========
Private Pages
~~~~~~~~~~~~~
Wagtail now supports password protecting pages on the frontend allowing sections of your website to be made private.
:ref:`private_pages`
Python 3 support
~~~~~~~~~~~~~~~~
Wagtail now supports Python 3.2, 3.3 and 3.4.
Scheduled publishing
~~~~~~~~~~~~~~~~~~~~
Editors can now schedule pages to be published or unpublished at specified times.
A new management command has been added (:ref:`publish_scheduled_pages`) to publish pages that have been scheduled by an Editor.
Search on QuerySet with Elasticsearch
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Its now possible to perform searches with Elasticsearch on ``PageQuerySet``s:
>>> from wagtail.wagtailcore.models import Page
>>> Page.objects.live().descendant_of(events_index).search("Hello")
[<Page: Event 1>, <Page: Event 2>]
Sitemap generation
~~~~~~~~~~~~~~~~~~
A new module has been added (``wagtail.contrib.wagtailsitemaps``) which produces XML sitemaps for Wagtail sites.
:ref:`sitemap_generation`
Front-end cache invalidation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A new module has been added (``wagtail.contrib.wagtailfrontendcache``) which invalidates pages in a frontend cache when they are updated or deleted in Wagtail.
:ref:`frontend_cache_purging`
Notification preferences
~~~~~~~~~~~~~~~~~~~~~~~~
Users can now decide which notifications they recieve from Wagtail using a new "Notification preferences" section located in the account settings.
Minor features
~~~~~~~~~~~~~~
Core
----
* Any extra arguments given to ``Page.serve`` are now passed through to ``get_context`` and ``get_template``
* Added ``in_menu`` and ``not_in_menu`` methods to ``PageQuerySet``
* Added ``get_next_siblings`` and ``get_prev_siblings`` to ``Page``
* Added ``page_published`` signal
* Added ``copy`` method to ``Page`` to allow copying of pages
* Added ``construct_whitelister_element_rules`` hook for customising the HTML whitelist used when saving ``RichText`` fields
* Support for setting a ``subpage_types`` property on ``Page`` models, to define which page types are allowed as subpages
Admin
-----
* Removed the "More" section from the menu
* Added pagination to page listings
* Added a new datetime picker widget
* Updated hallo.js to version 1.0.4
* Aesthetic improvements to preview experience
* Login screen redirects to dashboard if user is already logged in
* Snippets are now ordered alphabetically
* Added ``init_new_page`` signal
Images
------
* Added ``original`` as a resizing rule supported by the ``{% image %}`` tag
* ``image`` tag now accepts extra keyword arguments to be output as attributes on the img tag
* Added an ``attrs`` property to image rendition objects to output ``src``, ``width``, ``height`` and ``alt`` attributes all in one go
Other
-----
* Added styleguide (mainly for wagtail developers)
Bug fixes
~~~~~~~~~
* Animated GIFs are now coalesced before resizing
* Wand backend clones images before modifying them
* Admin breadcrumb now positioned correctly on mobile
* Page chooser breadcrumb now updates the chooser modal instead of linking to Explorer
* Embeds - Fixed crash when no HTML field is sent back from the embed provider
* Multiple sites with same hostname but different ports are now allowed
* No longer possible to create multiple sites with ``is_default_site = True``
Backwards incompatible changes
==============================
ElasticUtils replaced with elasticsearch-py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you are using the elasticsearch backend, you must install the ``elasticsearch`` module into your environment.
.. note::
If you are using an older version of Elasticsearch (< 1.0) you must install ``elasticsearch`` version 0.4.x.
Deprecated features
===================
Template tag libraries renamed
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The following template tag libraries have been renamed:
* ``pageurl`` => ``wagtailcore_tags``
* ``rich_text`` => ``wagtailcore_tags``
* ``embed_filters`` => ``wagtailembeds_tags``
* ``image_tags`` => ``wagtailimages_tags``
New search field configuration format
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``indexed_fields`` is now deprecated and has been replaced by a new search field configuration format called ``search_fields``.
``Page.route`` method should now return a ``RouteResult``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Previously, the ``route`` method called ``serve`` and returned a ``HttpResponse`` object. This has now been split up so ``serve`` is called separately and ``route`` must now return a RouteResult object.
:ref:`anatomy_of_a_wagtail_request`
Wagtailadmins ``hooks`` module has moved to wagtailcore
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you use any ``wagtail_hooks.py`` files in your project, you may have an import like: ``from wagtail.wagtailadmin import hooks``
Change this to: ``from wagtail.wagtailcore import hooks``
Miscellaneous
~~~~~~~~~~~~~
* ``Page.show_as_mode`` replaced with ``Page.serve_preview``
* ``Page.get_page_modes`` method replaced with ``Page.preview_modes`` property
* ``Page.get_other_siblings`` replaced with ``Page.get_siblings(inclusive=False)``

View file

@ -240,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
-------------------------------------

View file

@ -45,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):
...
@ -54,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

View file

@ -8,7 +8,7 @@ class Sitemap(object):
self.site = site
def get_pages(self):
return self.site.root_page.get_descendants(inclusive=True).live().order_by('path')
return self.site.root_page.get_descendants(inclusive=True).live().public().order_by('path')
def get_urls(self):
for page in self.get_pages():

View file

@ -1,7 +1,7 @@
from django.test import TestCase
from django.core.cache import cache
from wagtail.wagtailcore.models import Page, Site
from wagtail.wagtailcore.models import Page, PageViewRestriction, Site
from wagtail.tests.models import SimplePage
from .sitemap_generator import Sitemap
@ -23,6 +23,13 @@ class TestSitemapGenerator(TestCase):
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):
@ -31,6 +38,7 @@ class TestSitemapGenerator(TestCase):
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)
@ -49,6 +57,9 @@ class TestSitemapGenerator(TestCase):
# Make sure the unpublished page didn't make it into the xml
self.assertNotIn('/unpublished/', xml)
# Make sure the protected page didn't make it into the xml
self.assertNotIn('/protected/', xml)
class TestSitemapView(TestCase):
def test_sitemap_view(self):

View file

@ -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,
@ -257,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": "<p>Test with old style route method</p>"
}
},
{
"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": "<p>muahahahaha</p>"
}
},
{
"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": "<p>meet in the menswear department at noon</p>",
"cost": "free"
}
},
{
"pk": 1,
"model": "wagtailcore.site",
@ -514,5 +587,14 @@
"width": 0,
"height": 0
}
},
{
"pk": 1,
"model": "wagtailcore.pageviewrestriction",
"fields": {
"page": 11,
"password": "swordfish"
}
}
]

View file

@ -106,6 +106,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):
@ -166,6 +179,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'),

View file

@ -0,0 +1,15 @@
<!DOCTYPE HTML>
<html>
<head>
<title>{{ self.title }}</title>
</head>
<body>
<h1>{{ self.title }}</h1>
<p>This event is invitation only. Please enter your password to see the details.</p>
<form action="{{ action_url }}" method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Continue" />
</form>
</body>
</html>

View file

@ -3,17 +3,13 @@ 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
from wagtail.wagtailsearch import urls as wagtailsearch_urls
from wagtail.contrib.wagtailsitemaps.views import sitemap
# Signal handlers
from wagtail.wagtailsearch import register_signal_handlers as wagtailsearch_register_signal_handlers
wagtailsearch_register_signal_handlers()
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),

View file

@ -1,4 +1,5 @@
from django.contrib.auth.models import User
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
@ -21,3 +22,6 @@ class WagtailTestUtils(object):
self.client.login(username='test', password='password')
return user
def assertRegex(self, *args, **kwargs):
six.assertRegex(self, *args, **kwargs)

View file

@ -1,3 +1,5 @@
from django.http import HttpResponse
from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.whitelist import attribute_rule, check_url, allow_without_attributes
@ -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("<h1>bad googlebot no cookie</h1>")
hooks.register('before_serve_page', block_googlebot)

View file

@ -575,6 +575,11 @@ class BaseInlinePanel(EditHandler):
child_edit_handler_class(instance=subform.instance, form=subform)
)
# if this formset is valid, it may have been re-ordered; respect that
# in case the parent form errored and we need to re-render
if self.formset.can_order and self.formset.is_valid():
self.children = sorted(self.children, key=lambda x: x.form.cleaned_data['ORDER'])
empty_form = self.formset.empty_form
empty_form.fields['DELETE'].widget = forms.HiddenInput()
if self.formset.can_order:

View file

@ -75,3 +75,20 @@ class PasswordResetForm(PasswordResetForm):
raise forms.ValidationError(_("This email address is not recognised."))
return cleaned_data
class PageViewRestrictionForm(forms.Form):
restriction_type = forms.ChoiceField(label="Visibility", choices=[
('none', ugettext_lazy("Public")),
('password', ugettext_lazy("Private, accessible with the following password")),
], widget=forms.RadioSelect)
password = forms.CharField(required=False)
def clean(self):
cleaned_data = super(PageViewRestrictionForm, self).clean()
if cleaned_data.get('restriction_type') == 'password' and not cleaned_data.get('password'):
self._errors["password"] = self.error_class([_('This field is required.')])
del cleaned_data['password']
return cleaned_data

View file

@ -0,0 +1,18 @@
$(function() {
/* Interface to set permissions from the explorer / editor */
$('a.action-set-privacy').click(function() {
ModalWorkflow({
'url': this.href,
'responses': {
'setPermission': function(isPublic) {
if (isPublic) {
$('.privacy-indicator').removeClass('private').addClass('public');
} else {
$('.privacy-indicator').removeClass('public').addClass('private');
}
}
}
});
return false;
});
});

View file

@ -121,8 +121,8 @@
color:lighten($color-grey-2,30%);
-webkit-font-smoothing: auto;
font-size:0.80em;
margin:0 0.5em;
background:white url("#{$static-root}bg-dark-diag.svg");
margin:0 0.5em 0.5em;
background:white url( "#{$static-root}bg-dark-diag.svg");
&.primary{
color:$color-grey-2;
@ -136,6 +136,26 @@ a.status-tag.primary:hover{
color:$color-teal;
}
.privacy-indicator {
.label-private, .label-public{
&:before{
font-size:1.5em;
color:$color-teal;
}
}
&.public {
.label-private {
display: none;
}
}
&.private {
.label-public {
display: none;
}
}
}
/* free tagging tags from taggit */
.tag{
background-color:$color-teal;
@ -174,6 +194,7 @@ a.tag:hover{
}
}
/* make a block-level element inline */
.inline{
display:inline;

View file

@ -69,6 +69,10 @@ input, textarea, select, .richtext, .tagit{
outline:none;
background-color:$color-input-focus;
}
&:disabled, &[disabled], &:disabled:hover, &[disabled]:hover{
background-color:inherit;
cursor:not-allowed;
}
}
/* select boxes */
@ -135,6 +139,7 @@ input[type=radio]:before{
display:block;
content:"K";
width: 1em;
height:1em;
line-height: 1.1em;
padding: 4px;
background-color: white;
@ -741,6 +746,7 @@ input[type=submit], input[type=reset], input[type=button], .button, button{
.choice_field &,
.model_multiple_choice_field &,
.boolean_field &,
.choice_field &,
.model_choice_field &,
.image_field &,
.file_field &{

View file

@ -5,6 +5,10 @@ header{
margin-bottom:2em;
color:white;
a{
color:white;
}
h1, h2{
margin:0;
color:white;
@ -97,12 +101,6 @@ header{
}
}
.page-explorer header{
margin-bottom:0;
padding-bottom:0em;
}
@media screen and (min-width: $breakpoint-mobile){
header{
padding-top:1.5em;

View file

@ -343,7 +343,6 @@ ul.listing{
background:$color-teal-darker;
}
}
}
}
ul.listing{
@ -357,6 +356,8 @@ table.listing{
/* explorer specific tweaks */
.page-explorer .listing {
position:relative;
.index{
color:white;
background-color:$color-header-bg;
@ -366,6 +367,14 @@ table.listing{
padding-bottom:1.5em;
}
.privacy-indicator{
font-size:1em;
opacity:1;
position:absolute;
right:5%;
top:2em;
}
.title{
h2{
color:white;
@ -376,9 +385,15 @@ table.listing{
color:white;
}
}
}
}
.privacy-indicator{
font-size:0.9em;
opacity:0.7;
}
.table-headers{
.ord{
padding-right:0;

View file

@ -452,15 +452,6 @@ input[type="submit"] {
*overflow: visible; /* 4 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to content-box in IE 8/9.
* 2. Remove excess padding in IE 8/9.

View file

@ -0,0 +1,8 @@
{% load i18n %}
{% trans "Page privacy" as title_str %}
{% include "wagtailadmin/shared/header.html" with title=title_str icon="locked" %}
<div class="nice-padding">
<p>{% trans "This page has been made private by a parent page." %}</p>
<p>{% trans "You can edit the privacy settings on:" %} <a href="{% url 'wagtailadmin_pages_edit' page_with_restriction.id %}">{{ page_with_restriction.title }}</a>
</div>

View file

@ -0,0 +1,16 @@
{% load i18n %}
{% trans "Page privacy" as title_str %}
{% include "wagtailadmin/shared/header.html" with title=title_str icon="locked" %}
<div class="nice-padding">
<p>{% trans "<b>Note:</b> privacy changes apply to all children of this page too." %}</p>
<form action="{% url 'wagtailadmin_pages_set_privacy' page.id %}" method="POST">
{% csrf_token %}
<ul class="fields">
{% include "wagtailadmin/shared/field_as_li.html" with field=form.restriction_type %}
{% include "wagtailadmin/shared/field_as_li.html" with field=form.password li_classes="password-field" %}
</ul>
<input type="submit" value="Save" />
</form>
</div>

View file

@ -0,0 +1,19 @@
function(modal) {
$('form', modal.body).submit(function() {
modal.postForm(this.action, $(this).serialize());
return false;
});
var restrictionTypePasswordField = $("input[name='restriction_type'][value='password']", modal.body);
var passwordField = $("#id_password", modal.body);
function refreshFormFields() {
if (restrictionTypePasswordField.is(':checked')) {
passwordField.removeAttr('disabled');
} else {
passwordField.attr('disabled', true);
}
}
refreshFormFields();
$("input[name='restriction_type']", modal.body).change(refreshFormFields);
}

View file

@ -0,0 +1,4 @@
function(modal) {
modal.respond('setPermission', {% if is_public %}true{% else %}false{% endif %});
modal.close();
}

View file

@ -21,6 +21,7 @@
<script src="{{ STATIC_URL }}wagtailadmin/js/page-editor.js"></script>
<script src="{{ STATIC_URL }}wagtailadmin/js/page-chooser.js"></script>
<script src="{{ STATIC_URL }}admin/js/urlify.js"></script>
<script src="{{ STATIC_URL }}wagtailadmin/js/privacy-indicator.js"></script>
{% hook_output 'insert_editor_js' %}
{% endcompress %}

View file

@ -0,0 +1,23 @@
{% load i18n wagtailadmin_tags %}
{% test_page_is_public page as is_public %}
{% if not page_perms %}
{% page_permissions page as page_perms %}
{% endif %}
<div class="privacy-indicator {% if is_public %}public{% else %}private{% endif %}">
{% trans "Privacy" %}
{% if page_perms.can_set_view_restrictions %}
<a href="{% url 'wagtailadmin_pages_set_privacy' page.id %}" class="status-tag primary action-set-privacy">
{# labels are shown/hidden in CSS according to the 'private' / 'public' class on view-permission-indicator #}
<span class="label-public icon icon-unlocked">{% trans 'Public' %}</span>
<span class="label-private icon icon-locked">{% trans 'Private' %}</span>
</a>
{% else %}
{% if is_public %}
<span class="label-public status-tag primary icon icon-unlocked ">{% trans 'Public' %}</span>
{% else %}
<span class="label-private status-tag primary icon icon-locked">{% trans 'Private' %}</span>
{% endif %}
{% endif %}
</div>

View file

@ -6,6 +6,7 @@
{% block bodyclass %}menu-explorer page-editor{% endblock %}
{% block content %}
{% page_permissions page as page_perms %}
<header class="merged tab-merged nice-padding">
{% include "wagtailadmin/shared/breadcrumb.html" with page=page %}
@ -14,7 +15,14 @@
<h1 class="icon icon-doc-empty-inverse">{% blocktrans with title=page.title %}Editing <span>{{ title }}</span>{% endblocktrans %}</h1>
</div>
<div class="right col3">
{% trans "Status:" %} {% if page.live %}<a href="{{ page.url }}" class="status-tag {% if page.live %}primary{% endif %}">{{ page.status_string }}</a>{% else %}<span class="status-tag">{{ page.status_string }}</span>{% endif %}
{% trans "Status" %}
{% if page.live %}
<a href="{{ page.url }}" class="status-tag {% if page.live %}primary{% endif %}">{{ page.status_string }}</a>
{% else %}
<span class="status-tag">{{ page.status_string }}</span>
{% endif %}
{% include "wagtailadmin/pages/_privacy_indicator.html" with page=page page_perms=page_perms only %}
</div>
</div>
</header>
@ -22,8 +30,7 @@
<form id="page-edit-form" action="{% url 'wagtailadmin_pages_edit' page.id %}" method="POST">
{% csrf_token %}
{{ edit_handler.render_form_content }}
{% page_permissions page as page_perms %}
<footer>
<ul>
<li class="actions">

View file

@ -1,6 +1,5 @@
{% extends "wagtailadmin/base.html" %}
{% load i18n %}
{% load wagtailadmin_tags %}
{% load i18n wagtailadmin_tags compress %}
{% block titletag %}{% blocktrans with title=parent_page.title %}Exploring {{ title }}{% endblocktrans %}{% endblock %}
{% block bodyclass %}menu-explorer page-explorer {% if ordering == 'ord' %}reordering{% endif %}{% endblock %}
@ -17,10 +16,13 @@
{% page_permissions parent_page as parent_page_perms %}
{% include "wagtailadmin/pages/list.html" with sortable=1 allow_navigation=1 full_width=1 parent_page=parent_page orderable=parent_page_perms.can_reorder_children %}
</form>
{% endblock %}
{% block extra_js %}
{% comment %} modal-workflow is required by the view restrictions interface {% endcomment %}
<script src="{{ STATIC_URL }}wagtailadmin/js/modal-workflow.js"></script>
<script src="{{ STATIC_URL }}wagtailadmin/js/privacy-indicator.js"></script>
<script type="text/javascript">
{% if ordering == 'ord' %}
$(function() {

View file

@ -31,23 +31,42 @@
<tr class="index {% if not parent_page.live %} inactive{% endif %} {% if moving or choosing %}{% if parent_page.can_choose %}can-choose{% endif %}{% endif %}">
<td class="title" {% if orderable %}colspan="2"{% endif %}>
{% if moving %}
{% if parent_page.can_choose %}
<h2><a href="{% url 'wagtailadmin_pages_move_confirm' page_to_move.id parent_page.id %}">{{ parent_page.title }}</a></h2>
{% else %}
<h2>{{ parent_page.title }}</h2>
{% endif %}
<h2>
{% if parent_page.can_choose %}
<a href="{% url 'wagtailadmin_pages_move_confirm' page_to_move.id parent_page.id %}">{{ parent_page.title }}</a>
{% else %}
{{ parent_page.title }}
{% endif %}
{% test_page_is_public parent_page as is_public %}
{% if not is_public %}
<span class="privacy-indicator icon icon-locked" title="This page is protected from public view"></span>
{% endif %}
</h2>
{% elif choosing %}
{% if parent_page.can_choose %}
<h2><a class="choose-page" href="#{{ parent_page.id }}" data-id="{{ parent_page.id }}" data-title="{{ parent_page.title }}" data-url="{{ parent_page.url }}">{{ parent_page.title }}</a></h2>
{% else %}
<h2>{{ parent_page.title }}</h2>
{% endif %}
<h2>
{% if parent_page.can_choose %}
<a class="choose-page" href="#{{ parent_page.id }}" data-id="{{ parent_page.id }}" data-title="{{ parent_page.title }}" data-url="{{ parent_page.url }}">{{ parent_page.title }}</a>
{% else %}
{{ parent_page.title }}
{% endif %}
{% test_page_is_public parent_page as is_public %}
{% if not is_public %}
<span class="privacy-indicator icon icon-locked" title="This page is protected from public view"></span>
{% endif %}
</h2>
{% else %}
{% if parent_page_perms.can_edit and 'edit' not in hide_actions|default:'' %}
<h2><a href="{% url 'wagtailadmin_pages_edit' parent_page.id %}">{{ parent_page.title }}</a></h2>
{% else %}
<h2>{{ parent_page.title }}</h2>
{% endif %}
<h2>
{% if parent_page_perms.can_edit and 'edit' not in hide_actions|default:'' %}
<a href="{% url 'wagtailadmin_pages_edit' parent_page.id %}">{{ parent_page.title }}</a>
{% else %}
{{ parent_page.title }}
{% endif %}
</h2>
{% include "wagtailadmin/pages/_privacy_indicator.html" with page=parent_page %}
<ul class="actions">
{% if parent_page_perms.can_edit and 'edit' not in hide_actions|default:'' %}
<li><a href="{% url 'wagtailadmin_pages_edit' parent_page.id %}" class="button button-small">{% trans 'Edit' %}</a></li>
@ -159,6 +178,11 @@
{{ page.title }}
{% endif %}
{% endif %}
{% test_page_is_public page as is_public %}
{% if not is_public %}
<span class="privacy-indicator icon icon-locked" title="This page is protected from public view"></span>
{% endif %}
</h2>
{% if not moving and not choosing %}
<ul class="actions">
@ -249,4 +273,4 @@
</li>
</ul>
</div>
{% endif %}
{% endif %}

View file

@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _
from wagtail.wagtailadmin.menu import MenuItem
from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import get_navigation_menu_items, UserPagePermissionsProxy
from wagtail.wagtailcore.models import get_navigation_menu_items, UserPagePermissionsProxy, PageViewRestriction
from wagtail.wagtailcore.utils import camelcase_to_underscore
@ -61,7 +61,10 @@ def fieldtype(bound_field):
try:
return camelcase_to_underscore(bound_field.field.__class__.__name__)
except AttributeError:
return ""
try:
return camelcase_to_underscore(bound_field.__class__.__name__)
except AttributeError:
return ""
@register.filter
@ -88,6 +91,26 @@ def page_permissions(context, page):
return context['user_page_permissions'].for_page(page)
@register.assignment_tag(takes_context=True)
def test_page_is_public(context, page):
"""
Usage: {% test_page_is_public page as is_public %}
Sets 'is_public' to True iff there are no page view restrictions in place on
this page.
Caches the list of page view restrictions in the context, to avoid repeated
DB queries on repeated calls.
"""
if 'all_page_view_restriction_paths' not in context:
context['all_page_view_restriction_paths'] = PageViewRestriction.objects.select_related('page').values_list('page__path', flat=True)
is_private = any([
page.path.startswith(restricted_path)
for restricted_path in context['all_page_view_restriction_paths']
])
return not is_private
@register.simple_tag
def hook_output(hook_name):
"""

View file

@ -14,16 +14,10 @@ class TestAuthentication(TestCase, WagtailTestUtils):
"""
This tests that users can login and logout of the admin interface
"""
def setUp(self):
self.login()
def test_login_view(self):
"""
This tests that the login view responds with a login page
"""
# Logout so we can test the login view
self.client.logout()
# Get login page
response = self.client.get(reverse('wagtailadmin_login'))
@ -36,8 +30,8 @@ class TestAuthentication(TestCase, WagtailTestUtils):
This posts user credentials to the login view and checks that
the user was logged in successfully
"""
# Logout so we can test the login view
self.client.logout()
# Create user to log in with
user = User.objects.create_superuser(username='test', email='test@email.com', password='password')
# Post credentials to the login page
post_data = {
@ -59,16 +53,40 @@ class TestAuthentication(TestCase, WagtailTestUtils):
redirected to the admin dashboard if they try to access the login
page
"""
# Login
self.login()
# Get login page
response = self.client.get(reverse('wagtailadmin_login'))
# Check that the user was redirected to the dashboard
self.assertRedirects(response, reverse('wagtailadmin_home'))
def test_logged_in_as_non_privileged_user_doesnt_redirect(self):
"""
This tests that if the user is logged in but hasn't got permission
to access the admin, they are not redirected to the admin
This tests issue #431
"""
# Login as unprivileged user
User.objects.create(username='unprivileged', password='123')
self.client.login(username='unprivileged', password='123')
# Get login page
response = self.client.get(reverse('wagtailadmin_login'))
# Check that the user recieved a login page and was not redirected
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/login.html')
def test_logout(self):
"""
This tests that the user can logout
"""
# Login
self.login()
# Get logout page
response = self.client.get(reverse('wagtailadmin_logout'))
@ -83,9 +101,6 @@ class TestAuthentication(TestCase, WagtailTestUtils):
This tests that a not logged in user is redirected to the
login page
"""
# Logout
self.client.logout()
# Get dashboard
response = self.client.get(reverse('wagtailadmin_home'))
@ -98,9 +113,6 @@ class TestAuthentication(TestCase, WagtailTestUtils):
redirects to the correct place when the user has not set
the LOGIN_URL setting correctly
"""
# Logout
self.client.logout()
# Get dashboard with default LOGIN_URL setting
with self.settings(LOGIN_URL='django.contrib.auth.views.login'):
response = self.client.get(reverse('wagtailadmin_home'))

View file

@ -312,6 +312,10 @@ class TestInlinePanel(TestCase):
'ORDER': MagicMock()}
instance = FakeInstance()
cleaned_data = {
'ORDER': 0,
}
def __repr__(self):
return 'fake form'
@ -319,6 +323,9 @@ class TestInlinePanel(TestCase):
empty_form = FakeForm()
can_order = True
def is_valid(self):
return True
label = 'label'
help_text = 'help text'
errors = ['errors']

View file

@ -7,8 +7,8 @@ from django.core import mail
from django.core.paginator import Paginator
from django.utils import timezone
from wagtail.tests.models import SimplePage, EventPage, StandardIndex, BusinessIndex, BusinessChild, BusinessSubIndex
from wagtail.tests.utils import WagtailTestUtils
from wagtail.tests.models import SimplePage, EventPage, EventPageCarouselItem, StandardIndex, BusinessIndex, BusinessChild, BusinessSubIndex
from wagtail.tests.utils import unittest, WagtailTestUtils
from wagtail.wagtailcore.models import Page, PageRevision
from wagtail.wagtailcore.signals import page_published
from wagtail.wagtailusers.models import UserProfile
@ -654,6 +654,115 @@ class TestPageEdit(TestCase, WagtailTestUtils):
self.assertContains(response, "I&#39;ve been edited!")
class TestPageEditReordering(TestCase, WagtailTestUtils):
def setUp(self):
# Find root page
self.root_page = Page.objects.get(id=2)
# Add event page
self.event_page = EventPage()
self.event_page.title = "Event page"
self.event_page.slug = "event-page"
self.event_page.carousel_items = [
EventPageCarouselItem(caption='1234567', sort_order=1),
EventPageCarouselItem(caption='7654321', sort_order=2),
EventPageCarouselItem(caption='abcdefg', sort_order=3),
]
self.root_page.add_child(instance=self.event_page)
# Login
self.user = self.login()
def check_order(self, response, expected_order):
inline_panel = response.context['edit_handler'].children[0].children[9]
order = [child.form.instance.caption for child in inline_panel.children]
self.assertEqual(order, expected_order)
def test_order(self):
response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.event_page.id, )))
self.assertEqual(response.status_code, 200)
self.check_order(response, ['1234567', '7654321', 'abcdefg'])
def test_reorder(self):
post_data = {
'title': "Event page",
'slug': 'event-page',
'date_from': '01/01/2014',
'cost': '$10',
'audience': 'public',
'location': 'somewhere',
'related_links-INITIAL_FORMS': 0,
'related_links-MAX_NUM_FORMS': 1000,
'related_links-TOTAL_FORMS': 0,
'speakers-INITIAL_FORMS': 0,
'speakers-MAX_NUM_FORMS': 1000,
'speakers-TOTAL_FORMS': 0,
'carousel_items-INITIAL_FORMS': 3,
'carousel_items-MAX_NUM_FORMS': 1000,
'carousel_items-TOTAL_FORMS': 3,
'carousel_items-0-id': self.event_page.carousel_items.all()[0].id,
'carousel_items-0-caption': self.event_page.carousel_items.all()[0].caption,
'carousel_items-0-ORDER': 2,
'carousel_items-1-id': self.event_page.carousel_items.all()[1].id,
'carousel_items-1-caption': self.event_page.carousel_items.all()[1].caption,
'carousel_items-1-ORDER': 3,
'carousel_items-2-id': self.event_page.carousel_items.all()[2].id,
'carousel_items-2-caption': self.event_page.carousel_items.all()[2].caption,
'carousel_items-2-ORDER': 1,
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.event_page.id, )), post_data)
# Should be redirected to explorer page
self.assertRedirects(response, reverse('wagtailadmin_explore', args=(self.root_page.id, )))
# Check order
response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.event_page.id, )))
self.assertEqual(response.status_code, 200)
self.check_order(response, ['abcdefg', '1234567', '7654321'])
def test_reorder_with_validation_error(self):
post_data = {
'title': "", # Validation error
'slug': 'event-page',
'date_from': '01/01/2014',
'cost': '$10',
'audience': 'public',
'location': 'somewhere',
'related_links-INITIAL_FORMS': 0,
'related_links-MAX_NUM_FORMS': 1000,
'related_links-TOTAL_FORMS': 0,
'speakers-INITIAL_FORMS': 0,
'speakers-MAX_NUM_FORMS': 1000,
'speakers-TOTAL_FORMS': 0,
'carousel_items-INITIAL_FORMS': 3,
'carousel_items-MAX_NUM_FORMS': 1000,
'carousel_items-TOTAL_FORMS': 3,
'carousel_items-0-id': self.event_page.carousel_items.all()[0].id,
'carousel_items-0-caption': self.event_page.carousel_items.all()[0].caption,
'carousel_items-0-ORDER': 2,
'carousel_items-1-id': self.event_page.carousel_items.all()[1].id,
'carousel_items-1-caption': self.event_page.carousel_items.all()[1].caption,
'carousel_items-1-ORDER': 3,
'carousel_items-2-id': self.event_page.carousel_items.all()[2].id,
'carousel_items-2-caption': self.event_page.carousel_items.all()[2].caption,
'carousel_items-2-ORDER': 1,
}
response = self.client.post(reverse('wagtailadmin_pages_edit', args=(self.event_page.id, )), post_data)
self.assertEqual(response.status_code, 200)
self.check_order(response, ['abcdefg', '1234567', '7654321'])
class TestPageDelete(TestCase, WagtailTestUtils):
def setUp(self):
# Find root page

View file

@ -0,0 +1,261 @@
from django.test import TestCase
from django.core.urlresolvers import reverse
from wagtail.wagtailcore.models import Page, PageViewRestriction
from wagtail.tests.models import SimplePage
from wagtail.tests.utils import WagtailTestUtils
class TestSetPrivacyView(TestCase, WagtailTestUtils):
def setUp(self):
self.login()
# Create some pages
self.homepage = Page.objects.get(id=2)
self.public_page = self.homepage.add_child(instance=SimplePage(
title="Public page",
slug='public-page',
live=True,
))
self.private_page = self.homepage.add_child(instance=SimplePage(
title="Private page",
slug='private-page',
live=True,
))
PageViewRestriction.objects.create(page=self.private_page, password='password123')
self.private_child_page = self.private_page.add_child(instance=SimplePage(
title="Private child page",
slug='private-child-page',
live=True,
))
def test_get_public(self):
"""
This tests that a blank form is returned when a user opens the set_privacy view on a public page
"""
response = self.client.get(reverse('wagtailadmin_pages_set_privacy', args=(self.public_page.id, )))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/page_privacy/set_privacy.html')
self.assertEqual(response.context['page'].specific, self.public_page)
# Check form attributes
self.assertEqual(response.context['form']['restriction_type'].value(), 'none')
def test_get_private(self):
"""
This tests that the restriction type and password fields as set correctly when a user opens the set_privacy view on a public page
"""
response = self.client.get(reverse('wagtailadmin_pages_set_privacy', args=(self.private_page.id, )))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/page_privacy/set_privacy.html')
self.assertEqual(response.context['page'].specific, self.private_page)
# Check form attributes
self.assertEqual(response.context['form']['restriction_type'].value(), 'password')
self.assertEqual(response.context['form']['password'].value(), 'password123')
def test_get_private_child(self):
"""
This tests that the set_privacy view tells the user that the password restriction has been applied to an ancestor
"""
response = self.client.get(reverse('wagtailadmin_pages_set_privacy', args=(self.private_child_page.id, )))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'wagtailadmin/page_privacy/ancestor_privacy.html')
self.assertEqual(response.context['page_with_restriction'].specific, self.private_page)
def test_set_password_restriction(self):
"""
This tests that setting a password restriction using the set_privacy view works
"""
post_data = {
'restriction_type': 'password',
'password': 'helloworld',
}
response = self.client.post(reverse('wagtailadmin_pages_set_privacy', args=(self.public_page.id, )), post_data)
# Check response
self.assertEqual(response.status_code, 200)
self.assertContains(response, "modal.respond('setPermission', false);")
# Check that a page restriction has been created
self.assertTrue(PageViewRestriction.objects.filter(page=self.public_page).exists())
# Check that the password is set correctly
self.assertEqual(PageViewRestriction.objects.get(page=self.public_page).password, 'helloworld')
def test_set_password_restriction_password_unset(self):
"""
This tests that the password field on the form is validated correctly
"""
post_data = {
'restriction_type': 'password',
'password': '',
}
response = self.client.post(reverse('wagtailadmin_pages_set_privacy', args=(self.public_page.id, )), post_data)
# Check response
self.assertEqual(response.status_code, 200)
# Check that a form error was raised
self.assertFormError(response, 'form', 'password', "This field is required.")
def test_unset_password_restriction(self):
"""
This tests that removing a password restriction using the set_privacy view works
"""
post_data = {
'restriction_type': 'none',
'password': '',
}
response = self.client.post(reverse('wagtailadmin_pages_set_privacy', args=(self.private_page.id, )), post_data)
# Check response
self.assertEqual(response.status_code, 200)
self.assertContains(response, "modal.respond('setPermission', true);")
# Check that the page restriction has been deleted
self.assertFalse(PageViewRestriction.objects.filter(page=self.private_page).exists())
class TestPrivacyIndicators(TestCase, WagtailTestUtils):
def setUp(self):
self.login()
# Create some pages
self.homepage = Page.objects.get(id=2)
self.public_page = self.homepage.add_child(instance=SimplePage(
title="Public page",
slug='public-page',
live=True,
))
self.private_page = self.homepage.add_child(instance=SimplePage(
title="Private page",
slug='private-page',
live=True,
))
PageViewRestriction.objects.create(page=self.private_page, password='password123')
self.private_child_page = self.private_page.add_child(instance=SimplePage(
title="Private child page",
slug='private-child-page',
live=True,
))
def test_explorer_public(self):
"""
This tests that the privacy indicator on the public pages explore view is set to "PUBLIC"
"""
response = self.client.get(reverse('wagtailadmin_explore', args=(self.public_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator public">')
self.assertNotContains(response, '<div class="privacy-indicator private">')
def test_explorer_private(self):
"""
This tests that the privacy indicator on the private pages explore view is set to "PRIVATE"
"""
response = self.client.get(reverse('wagtailadmin_explore', args=(self.private_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator private">')
self.assertNotContains(response, '<div class="privacy-indicator public">')
def test_explorer_private_child(self):
"""
This tests that the privacy indicator on the private child pages explore view is set to "PRIVATE"
"""
response = self.client.get(reverse('wagtailadmin_explore', args=(self.private_child_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator private">')
self.assertNotContains(response, '<div class="privacy-indicator public">')
def test_explorer_list_homepage(self):
"""
This tests that there is a padlock displayed next to the private page in the homepages explorer listing
"""
response = self.client.get(reverse('wagtailadmin_explore', args=(self.homepage.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Must have one privacy icon (next to the private page)
self.assertContains(response, "<span class=\"privacy-indicator icon icon-locked\"", count=1)
def test_explorer_list_private(self):
"""
This tests that there is a padlock displayed next to the private child page in the private pages explorer listing
"""
response = self.client.get(reverse('wagtailadmin_explore', args=(self.private_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Must have one privacy icon (next to the private child page)
self.assertContains(response, "<span class=\"privacy-indicator icon icon-locked\"", count=1)
def test_edit_public(self):
"""
This tests that the privacy indicator on the public pages edit view is set to "PUBLIC"
"""
response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.public_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator public">')
self.assertNotContains(response, '<div class="privacy-indicator private">')
def test_edit_private(self):
"""
This tests that the privacy indicator on the private pages edit view is set to "PRIVATE"
"""
response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.private_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator private">')
self.assertNotContains(response, '<div class="privacy-indicator public">')
def test_edit_private_child(self):
"""
This tests that the privacy indicator on the private child pages edit view is set to "PRIVATE"
"""
response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.private_child_page.id, )))
# Check the response
self.assertEqual(response.status_code, 200)
# Check the privacy indicator is public
self.assertTemplateUsed(response, 'wagtailadmin/pages/_privacy_indicator.html')
self.assertContains(response, '<div class="privacy-indicator private">')
self.assertNotContains(response, '<div class="privacy-indicator public">')

View file

@ -1,7 +1,7 @@
from django.conf.urls import url
from wagtail.wagtailadmin.forms import PasswordResetForm
from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar
from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar, page_privacy
from wagtail.wagtailcore import hooks
@ -67,6 +67,8 @@ urlpatterns += [
url(r'^pages/moderation/(\d+)/reject/$', pages.reject_moderation, name='wagtailadmin_pages_reject_moderation'),
url(r'^pages/moderation/(\d+)/preview/$', pages.preview_for_moderation, name='wagtailadmin_pages_preview_for_moderation'),
url(r'^pages/(\d+)/privacy/$', page_privacy.set_privacy, name='wagtailadmin_pages_set_privacy'),
url(r'^choose-page/$', chooser.browse, name='wagtailadmin_choose_page'),
url(r'^choose-page/(\d+)/$', chooser.browse, name='wagtailadmin_choose_page_child'),
url(r'^choose-external-link/$', chooser.external_link, name='wagtailadmin_choose_page_external_link'),

View file

@ -75,7 +75,7 @@ def notification_preferences(request):
@sensitive_post_parameters()
@never_cache
def login(request):
if request.user.is_authenticated():
if request.user.is_authenticated() and request.user.has_perm('wagtailadmin.access_admin'):
return redirect('wagtailadmin_home')
else:
return auth_login(request,

View file

@ -0,0 +1,77 @@
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404
from wagtail.wagtailcore.models import Page, PageViewRestriction
from wagtail.wagtailadmin.forms import PageViewRestrictionForm
from wagtail.wagtailadmin.modal_workflow import render_modal_workflow
@permission_required('wagtailadmin.access_admin')
def set_privacy(request, page_id):
page = get_object_or_404(Page, id=page_id)
page_perms = page.permissions_for_user(request.user)
if not page_perms.can_set_view_restrictions():
raise PermissionDenied
# fetch restriction records in depth order so that ancestors appear first
restrictions = page.get_view_restrictions().order_by('page__depth')
if restrictions:
restriction = restrictions[0]
restriction_exists_on_ancestor = (restriction.page != page)
else:
restriction = None
restriction_exists_on_ancestor = False
if request.POST:
form = PageViewRestrictionForm(request.POST)
if form.is_valid() and not restriction_exists_on_ancestor:
if form.cleaned_data['restriction_type'] == 'none':
# remove any existing restriction
if restriction:
restriction.delete()
else: # restriction_type = 'password'
if restriction:
restriction.password = form.cleaned_data['password']
restriction.save()
else:
# create a new restriction object
PageViewRestriction.objects.create(
page=page, password = form.cleaned_data['password'])
return render_modal_workflow(
request, None, 'wagtailadmin/page_privacy/set_privacy_done.js', {
'is_public': (form.cleaned_data['restriction_type'] == 'none')
}
)
else: # request is a GET
if not restriction_exists_on_ancestor:
if restriction:
form = PageViewRestrictionForm(initial={
'restriction_type': 'password', 'password': restriction.password
})
else:
# no current view restrictions on this page
form = PageViewRestrictionForm(initial={
'restriction_type': 'none'
})
if restriction_exists_on_ancestor:
# display a message indicating that there is a restriction at ancestor level -
# do not provide the form for setting up new restrictions
return render_modal_workflow(
request, 'wagtailadmin/page_privacy/ancestor_privacy.html', None,
{
'page_with_restriction': restriction.page,
}
)
else:
# no restriction set at ancestor level - can set restrictions here
return render_modal_workflow(
request,
'wagtailadmin/page_privacy/set_privacy.html',
'wagtailadmin/page_privacy/set_privacy.js', {
'page': page,
'form': form,
}
)

View file

@ -0,0 +1,16 @@
from django import forms
class PasswordPageViewRestrictionForm(forms.Form):
password = forms.CharField(label="Password", widget=forms.PasswordInput)
return_url = forms.CharField(widget=forms.HiddenInput)
def __init__(self, *args, **kwargs):
self.restriction = kwargs.pop('instance')
super(PasswordPageViewRestrictionForm, self).__init__(*args, **kwargs)
def clean_password(self):
data = self.cleaned_data['password']
if data != self.restriction.password:
raise forms.ValidationError("The password you have entered is not correct. Please try again.")
return data

View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
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):
def forwards(self, orm):
# Adding model 'PageViewRestriction'
db.create_table('wagtailcore_pageviewrestriction', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('page', self.gf('django.db.models.fields.related.ForeignKey')(related_name='view_restrictions', to=orm['wagtailcore.Page'])),
('password', self.gf('django.db.models.fields.CharField')(max_length=255)),
))
db.send_create_signal('wagtailcore', ['PageViewRestriction'])
def backwards(self, orm):
# Deleting model 'PageViewRestriction'
db.delete_table('wagtailcore_pageviewrestriction')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'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': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'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': "'user_set'", 'blank': 'True', 'to': "orm['auth.Group']"}),
'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': "'user_set'", 'blank': 'True', 'to': "orm['auth.Permission']"}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'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'}),
'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'})
},
'wagtailcore.grouppagepermission': {
'Meta': {'object_name': 'GroupPagePermission'},
'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': "orm['auth.Group']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': "orm['wagtailcore.Page']"}),
'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'})
},
'wagtailcore.page': {
'Meta': {'object_name': 'Page'},
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': "orm['contenttypes.ContentType']"}),
'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
'expire_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'expired': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'go_live_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': "orm['auth.User']"}),
'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'})
},
'wagtailcore.pagerevision': {
'Meta': {'object_name': 'PageRevision'},
'approved_go_live_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}),
'content_json': ('django.db.models.fields.TextField', [], {}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': "orm['wagtailcore.Page']"}),
'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'})
},
'wagtailcore.pageviewrestriction': {
'Meta': {'object_name': 'PageViewRestriction'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'view_restrictions'", 'to': "orm['wagtailcore.Page']"}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '255'})
},
'wagtailcore.site': {
'Meta': {'unique_together': "(('hostname', 'port'),)", 'object_name': 'Site'},
'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}),
'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': "orm['wagtailcore.Page']"})
}
}
complete_apps = ['wagtailcore']

View file

@ -27,6 +27,7 @@ from treebeard.mp_tree import MP_Node
from wagtail.wagtailcore.utils import camelcase_to_underscore
from wagtail.wagtailcore.query import PageQuerySet
from wagtail.wagtailcore.url_routing import RouteResult
from wagtail.wagtailsearch import indexed
from wagtail.wagtailsearch.backends import get_search_backend
@ -228,6 +229,12 @@ class PageManager(models.Manager):
def not_type(self, model):
return self.get_queryset().not_type(model)
def public(self):
return self.get_queryset().public()
def not_public(self):
return self.get_queryset().not_public()
class PageBase(models.base.ModelBase):
"""Metaclass for Page"""
@ -406,7 +413,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Index
else:
# request is for this very page
if self.live:
return self.serve(request)
return RouteResult(self)
else:
raise Http404
@ -800,6 +807,24 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Index
def get_prev_siblings(self, inclusive=False):
return self.get_siblings(inclusive).filter(path__lte=self.path).order_by('-path')
def get_view_restrictions(self):
"""Return a query set of all page view restrictions that apply to this page"""
return PageViewRestriction.objects.filter(page__in=self.get_ancestors(inclusive=True))
password_required_template = getattr(settings, 'PASSWORD_REQUIRED_TEMPLATE', 'wagtailcore/password_required.html')
def serve_password_required_response(self, request, form, action_url):
"""
Serve a response indicating that the user has been denied access to view this page,
and must supply a password.
form = a Django form object containing the password input
(and zero or more hidden fields that also need to be output on the template)
action_url = URL that this form should be POSTed to
"""
context = self.get_context(request)
context['form'] = form
context['action_url'] = action_url
return TemplateResponse(request, self.password_required_template, context)
def get_navigation_menu_items():
# Get all pages that appear in the navigation menu: ones which have children,
@ -1084,6 +1109,9 @@ class PagePermissionTester(object):
return self.user.is_superuser or ('publish' in self.permissions)
def can_set_view_restrictions(self):
return self.can_publish()
def can_publish_subpage(self):
"""
Niggly special case for creating and publishing a page in one go.
@ -1141,3 +1169,8 @@ class PagePermissionTester(object):
else:
# no publishing required, so the already-tested 'add' permission is sufficient
return True
class PageViewRestriction(models.Model):
page = models.ForeignKey('Page', related_name='view_restrictions')
password = models.CharField(max_length=255)

View file

@ -107,3 +107,17 @@ class PageQuerySet(MP_NodeQuerySet):
def not_type(self, model):
return self.exclude(self.type_q(model))
def public_q(self):
from wagtail.wagtailcore.models import PageViewRestriction
q = Q()
for restriction in PageViewRestriction.objects.all():
q &= ~self.descendant_of_q(restriction.page, inclusive=True)
return q
def public(self):
return self.filter(self.public_q())
def not_public(self):
return self.exclude(self.public_q())

View file

@ -0,0 +1,15 @@
<!DOCTYPE HTML>
<html>
<head>
<title>Password required</title>
</head>
<body>
<h1>Password required</h1>
<p>You need a password to access this page.</p>
<form action="{{ action_url }}" method="POST">
{% csrf_token %}
{{ form.as_p }}
<input type="submit" value="Continue" />
</form>
</body>
</html>

View file

@ -1,8 +1,10 @@
import warnings
from django.test import TestCase, Client
from django.http import HttpRequest, Http404
from wagtail.wagtailcore.models import Page, Site
from wagtail.tests.models import EventPage, EventIndex, SimplePage
from wagtail.tests.models import EventPage, EventIndex, SimplePage, PageWithOldStyleRouteMethod
class TestSiteRouting(TestCase):
@ -136,8 +138,13 @@ class TestRouting(TestCase):
request = HttpRequest()
request.path = '/events/christmas/'
response = homepage.route(request, ['events', 'christmas'])
(found_page, args, kwargs) = homepage.route(request, ['events', 'christmas'])
self.assertEqual(found_page, christmas_page)
def test_request_serving(self):
christmas_page = EventPage.objects.get(url_path='/home/events/christmas/')
request = HttpRequest()
response = christmas_page.serve(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context_data['self'], christmas_page)
used_template = response.resolve_template(response.template_name)
@ -226,6 +233,28 @@ class TestServeView(TestCase):
self.assertContains(response, '<a href="/events/christmas/">Christmas</a>')
def test_old_style_routing(self):
"""
Test that route() methods that return an HttpResponse are correctly handled
"""
with warnings.catch_warnings(record=True) as w:
response = self.client.get('/old-style-route/')
# Check that a DeprecationWarning has been triggered
self.assertEqual(len(w), 1)
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
self.assertTrue("Page.route should return an instance of wagtailcore.url_routing.RouteResult" in str(w[-1].message))
expected_page = PageWithOldStyleRouteMethod.objects.get(url_path='/home/old-style-route/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['self'], expected_page)
self.assertEqual(response.templates[0].name, 'tests/simple_page.html')
def test_before_serve_hook(self):
response = self.client.get('/events/', HTTP_USER_AGENT='GoogleBot')
self.assertContains(response, 'bad googlebot no cookie')
class TestStaticSitePaths(TestCase):
def setUp(self):
self.root_page = Page.objects.get(id=1)

View file

@ -0,0 +1,70 @@
from django.test import TestCase
from wagtail.wagtailcore.models import Page, PageViewRestriction
class TestPagePrivacy(TestCase):
fixtures = ['test.json']
def setUp(self):
self.secret_plans_page = Page.objects.get(url_path='/home/secret-plans/')
self.view_restriction = PageViewRestriction.objects.get(
page=self.secret_plans_page)
def test_anonymous_user_must_authenticate(self):
response = self.client.get('/secret-plans/')
self.assertEqual(response.templates[0].name, 'wagtailcore/password_required.html')
submit_url = "/_util/authenticate_with_password/%d/%d/" % (self.view_restriction.id, self.secret_plans_page.id)
self.assertContains(response, '<form action="%s"' % submit_url)
self.assertContains(response, '<input id="id_return_url" name="return_url" type="hidden" value="/secret-plans/" />')
# posting the wrong password should redisplay the password page
response = self.client.post(submit_url, {
'password': 'wrongpassword',
'return_url': '/secret-plans/',
})
self.assertEqual(response.templates[0].name, 'wagtailcore/password_required.html')
self.assertContains(response, '<form action="%s"' % submit_url)
# posting the correct password should redirect back to return_url
response = self.client.post(submit_url, {
'password': 'swordfish',
'return_url': '/secret-plans/',
})
self.assertRedirects(response, '/secret-plans/')
# now requests to /secret-plans/ should pass authentication
response = self.client.get('/secret-plans/')
self.assertEqual(response.templates[0].name, 'tests/simple_page.html')
def test_view_restrictions_apply_to_subpages(self):
underpants_page = Page.objects.get(url_path='/home/secret-plans/steal-underpants/')
response = self.client.get('/secret-plans/steal-underpants/')
# check that we're overriding the default password_required template for this page type
self.assertEqual(response.templates[0].name, 'tests/event_page_password_required.html')
submit_url = "/_util/authenticate_with_password/%d/%d/" % (self.view_restriction.id, underpants_page.id)
self.assertContains(response, '<title>Steal underpants</title>')
self.assertContains(response, '<form action="%s"' % submit_url)
self.assertContains(response, '<input id="id_return_url" name="return_url" type="hidden" value="/secret-plans/steal-underpants/" />')
# posting the wrong password should redisplay the password page
response = self.client.post(submit_url, {
'password': 'wrongpassword',
'return_url': '/secret-plans/steal-underpants/',
})
self.assertEqual(response.templates[0].name, 'tests/event_page_password_required.html')
self.assertContains(response, '<form action="%s"' % submit_url)
# posting the correct password should redirect back to return_url
response = self.client.post(submit_url, {
'password': 'swordfish',
'return_url': '/secret-plans/steal-underpants/',
})
self.assertRedirects(response, '/secret-plans/steal-underpants/')
# now requests to /secret-plans/ should pass authentication
response = self.client.get('/secret-plans/steal-underpants/')
self.assertEqual(response.templates[0].name, 'tests/event_page.html')

View file

@ -1,6 +1,6 @@
from django.test import TestCase
from wagtail.wagtailcore.models import Page
from wagtail.wagtailcore.models import Page, PageViewRestriction
from wagtail.tests.models import EventPage
@ -270,3 +270,43 @@ class TestPageQuerySet(TestCase):
# Check that the homepage is in the results
homepage = Page.objects.get(url_path='/home/')
self.assertTrue(pages.filter(id=homepage.id).exists())
def test_public(self):
events_index = Page.objects.get(url_path='/home/events/')
event = Page.objects.get(url_path='/home/events/christmas/')
homepage = Page.objects.get(url_path='/home/')
# Add PageViewRestriction to events_index
PageViewRestriction.objects.create(page=events_index, password='hello')
# Get public pages
pages = Page.objects.public()
# Check that the homepage is in the results
self.assertTrue(pages.filter(id=homepage.id).exists())
# Check that the events index is not in the results
self.assertFalse(pages.filter(id=events_index.id).exists())
# Check that the event is not in the results
self.assertFalse(pages.filter(id=event.id).exists())
def test_not_public(self):
events_index = Page.objects.get(url_path='/home/events/')
event = Page.objects.get(url_path='/home/events/christmas/')
homepage = Page.objects.get(url_path='/home/')
# Add PageViewRestriction to events_index
PageViewRestriction.objects.create(page=events_index, password='hello')
# Get public pages
pages = Page.objects.not_public()
# Check that the homepage is not in the results
self.assertFalse(pages.filter(id=homepage.id).exists())
# Check that the events index is in the results
self.assertTrue(pages.filter(id=events_index.id).exists())
# Check that the event is in the results
self.assertTrue(pages.filter(id=event.id).exists())

View file

@ -0,0 +1,15 @@
class RouteResult(object):
"""
An object to be returned from Page.route, which encapsulates
all the information necessary to serve an HTTP response. Analogous to
django.core.urlresolvers.ResolverMatch, except that it identifies
a Page instance that we will call serve(*args, **kwargs) on, rather
than a view function.
"""
def __init__(self, page, args=None, kwargs=None):
self.page = page
self.args = args or []
self.kwargs = kwargs or {}
def __getitem__(self, index):
return (self.page, self.args, self.kwargs)[index]

View file

@ -2,7 +2,10 @@ from django.conf.urls import url
from wagtail.wagtailcore import views
urlpatterns = [
# All front-end views are handled through Wagtail's core.views.serve mechanism.
url(r'^_util/authenticate_with_password/(\d+)/(\d+)/$', views.authenticate_with_password,
name='wagtailcore_authenticate_with_password'),
# Front-end page views are handled through Wagtail's core.views.serve mechanism.
# Here we match a (possibly empty) list of path segments, each followed by
# a '/'. If a trailing slash is not present, we leave CommonMiddleware to
# handle it as usual (i.e. redirect it to the trailing slash version if

View file

@ -1,4 +1,13 @@
from django.http import Http404
import warnings
from django.http import HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect
from django.core.urlresolvers import reverse
from django.conf import settings
from wagtail.wagtailcore import hooks
from wagtail.wagtailcore.models import Page, PageViewRestriction
from wagtail.wagtailcore.forms import PasswordPageViewRestrictionForm
def serve(request, path):
@ -8,4 +17,47 @@ def serve(request, path):
raise Http404
path_components = [component for component in path.split('/') if component]
return request.site.root_page.specific.route(request, path_components)
route_result = request.site.root_page.specific.route(request, path_components)
if isinstance(route_result, HttpResponse):
warnings.warn(
"Page.route should return an instance of wagtailcore.url_routing.RouteResult, not an HttpResponse",
DeprecationWarning
)
return route_result
(page, args, kwargs) = route_result
for fn in hooks.get_hooks('before_serve_page'):
result = fn(page, request, args, kwargs)
if isinstance(result, HttpResponse):
return result
return page.serve(request, *args, **kwargs)
def authenticate_with_password(request, page_view_restriction_id, page_id):
"""
Handle a submission of PasswordPageViewRestrictionForm to grant view access over a
subtree that is protected by a PageViewRestriction
"""
restriction = get_object_or_404(PageViewRestriction, id=page_view_restriction_id)
page = get_object_or_404(Page, id=page_id).specific
if request.POST:
form = PasswordPageViewRestrictionForm(request.POST, instance=restriction)
if form.is_valid():
has_existing_session = (settings.SESSION_COOKIE_NAME in request.COOKIES)
passed_restrictions = request.session.setdefault('passed_page_view_restrictions', [])
if restriction.id not in passed_restrictions:
passed_restrictions.append(restriction.id)
request.session['passed_page_view_restrictions'] = passed_restrictions
if not has_existing_session:
# if this is a session we've created, set it to expire at the end
# of the browser session
request.session.set_expiry(0)
return redirect(form.cleaned_data['return_url'])
else:
form = PasswordPageViewRestrictionForm(instance=restriction)
action_url = reverse('wagtailcore_authenticate_with_password', args=[restriction.id, page.id])
return page.serve_password_required_response(request, form, action_url)

View file

@ -0,0 +1,25 @@
from django.core.urlresolvers import reverse
from wagtail.wagtailcore import hooks
def check_view_restrictions(page, request, serve_args, serve_kwargs):
"""
Check whether there are any view restrictions on this page which are
not fulfilled by the given request object. If there are, return an
HttpResponse that will notify the user of that restriction (and possibly
include a password / login form that will allow them to proceed). If
there are no such restrictions, return None
"""
restrictions = page.get_view_restrictions()
if restrictions:
passed_restrictions = request.session.get('passed_page_view_restrictions', [])
for restriction in restrictions:
if restriction.id not in passed_restrictions:
from wagtail.wagtailcore.forms import PasswordPageViewRestrictionForm
form = PasswordPageViewRestrictionForm(instance=restriction,
initial={'return_url': request.get_full_path()})
action_url = reverse('wagtailcore_authenticate_with_password', args=[restriction.id, page.id])
return page.serve_password_required_response(request, form, action_url)
hooks.register('before_serve_page', check_view_restrictions)

View file

@ -22,7 +22,7 @@ class DBSearch(BaseSearch):
pass # Not needed
def add_bulk(self, obj_list):
pass # Not needed
return [] # Not needed
def delete(self, obj):
pass # Not needed
@ -38,7 +38,7 @@ class DBSearch(BaseSearch):
# Get fields
if fields is None:
fields = model.indexed_get_indexed_fields().keys()
fields = [field.field_name for field in model.get_searchable_search_fields()]
# Start will all objects
query = model.objects.all()
@ -49,7 +49,7 @@ class DBSearch(BaseSearch):
# Filter by terms
for term in terms:
term_query = None
term_query = models.Q()
for field_name in fields:
# Check if the field exists (this will filter out indexed callables)
try:
@ -58,11 +58,8 @@ class DBSearch(BaseSearch):
continue
# Filter on this field
field_filter = {'%s__icontains' % field_name: term}
if term_query is None:
term_query = models.Q(**field_filter)
else:
term_query |= models.Q(**field_filter)
term_query |= models.Q(**{'%s__icontains' % field_name: term})
query = query.filter(term_query)
# Distinct
@ -73,4 +70,4 @@ class DBSearch(BaseSearch):
for prefetch in prefetch_related:
query = query.prefetch_related(prefetch)
return query
return query

View file

@ -8,15 +8,112 @@ from elasticsearch import Elasticsearch, NotFoundError, RequestError
from elasticsearch.helpers import bulk
from wagtail.wagtailsearch.backends.base import BaseSearch
from wagtail.wagtailsearch.indexed import Indexed
from wagtail.wagtailsearch.indexed import Indexed, SearchField, FilterField
from wagtail.wagtailsearch.utils import normalise_query_string
class ElasticSearchMapping(object):
TYPE_MAP = {
'AutoField': 'integer',
'BinaryField': 'binary',
'BooleanField': 'boolean',
'CharField': 'string',
'CommaSeparatedIntegerField': 'string',
'DateField': 'date',
'DateTimeField': 'date',
'DecimalField': 'double',
'FileField': 'string',
'FilePathField': 'string',
'FloatField': 'double',
'IntegerField': 'integer',
'BigIntegerField': 'long',
'IPAddressField': 'string',
'GenericIPAddressField': 'string',
'NullBooleanField': 'boolean',
'OneToOneField': 'integer',
'PositiveIntegerField': 'integer',
'PositiveSmallIntegerField': 'integer',
'SlugField': 'string',
'SmallIntegerField': 'integer',
'TextField': 'string',
'TimeField': 'date',
}
def __init__(self, model):
self.model = model
def get_document_type(self):
return self.model.indexed_get_content_type()
def get_field_mapping(self, field):
mapping = {'type': self.TYPE_MAP.get(field.get_type(self.model), 'string')}
if isinstance(field, SearchField):
if field.boost:
mapping['boost'] = field.boost
if field.partial_match:
mapping['analyzer'] = 'edgengram_analyzer'
mapping['include_in_all'] = True
elif isinstance(field, FilterField):
mapping['index'] = 'not_analyzed'
mapping['include_in_all'] = False
if 'es_extra' in field.kwargs:
for key, value in field.kwargs['es_extra'].items():
mapping[key] = value
return field.get_index_name(self.model), mapping
def get_mapping(self):
# Make field list
fields = {
'pk': dict(type='string', index='not_analyzed', store='yes', include_in_all=False),
'content_type': dict(type='string', index='not_analyzed', include_in_all=False),
'_partials': dict(type='string', analyzer='edgengram_analyzer', include_in_all=False),
}
fields.update(dict(
self.get_field_mapping(field) for field in self.model.get_search_fields()
))
return {
self.get_document_type(): {
'properties': fields,
}
}
def get_document_id(self, obj):
return obj.indexed_get_toplevel_content_type() + ':' + str(obj.pk)
def get_document(self, obj):
# Build document
doc = dict(pk=str(obj.pk), content_type=self.model.indexed_get_content_type())
partials = []
for field in self.model.get_search_fields():
value = field.get_value(obj)
doc[field.get_index_name(self.model)] = value
# Check if this field should be added into _partials
if isinstance(field, SearchField) and field.partial_match:
partials.append(value)
# Add partials to document
doc['_partials'] = partials
return doc
def __repr__(self):
return '<ElasticSearchMapping: %s>' % (self.model.__name__, )
class ElasticSearchQuery(object):
def __init__(self, model, query_string, fields=None, filters={}):
self.model = model
self.query_string = query_string
self.fields = fields or ['_all']
self.fields = fields or ['_all', '_partials']
self.filters = filters
def _get_filters(self):
@ -283,43 +380,43 @@ class ElasticSearch(BaseSearch):
# Settings
INDEX_SETTINGS = {
"settings": {
"analysis": {
"analyzer": {
"ngram_analyzer": {
"type": "custom",
"tokenizer": "lowercase",
"filter": ["ngram"]
'settings': {
'analysis': {
'analyzer': {
'ngram_analyzer': {
'type': 'custom',
'tokenizer': 'lowercase',
'filter': ['ngram']
},
"edgengram_analyzer": {
"type": "custom",
"tokenizer": "lowercase",
"filter": ["edgengram"]
'edgengram_analyzer': {
'type': 'custom',
'tokenizer': 'lowercase',
'filter': ['edgengram']
}
},
"tokenizer": {
"ngram_tokenizer": {
"type": "nGram",
"min_gram": 3,
"max_gram": 15,
'tokenizer': {
'ngram_tokenizer': {
'type': 'nGram',
'min_gram': 3,
'max_gram': 15,
},
"edgengram_tokenizer": {
"type": "edgeNGram",
"min_gram": 2,
"max_gram": 15,
"side": "front"
'edgengram_tokenizer': {
'type': 'edgeNGram',
'min_gram': 2,
'max_gram': 15,
'side': 'front'
}
},
"filter": {
"ngram": {
"type": "nGram",
"min_gram": 3,
"max_gram": 15
'filter': {
'ngram': {
'type': 'nGram',
'min_gram': 3,
'max_gram': 15
},
"edgengram": {
"type": "edgeNGram",
"min_gram": 1,
"max_gram": 15
'edgengram': {
'type': 'edgeNGram',
'min_gram': 1,
'max_gram': 15
}
}
}
@ -330,25 +427,11 @@ class ElasticSearch(BaseSearch):
self.es.indices.create(self.es_index, INDEX_SETTINGS)
def add_type(self, model):
# Get type name
content_type = model.indexed_get_content_type()
# Get indexed fields
indexed_fields = model.indexed_get_indexed_fields()
# Make field list
fields = {
"pk": dict(type="string", index="not_analyzed", store="yes"),
"content_type": dict(type="string"),
}
fields.update(indexed_fields)
# Get mapping
mapping = ElasticSearchMapping(model)
# Put mapping
self.es.indices.put_mapping(index=self.es_index, doc_type=content_type, body={
content_type: {
"properties": fields,
}
})
self.es.indices.put_mapping(index=self.es_index, doc_type=mapping.get_document_type(), body=mapping.get_mapping())
def refresh_index(self):
self.es.indices.refresh(self.es_index)
@ -358,11 +441,11 @@ class ElasticSearch(BaseSearch):
if not self.object_can_be_indexed(obj):
return
# Build document
doc = obj.indexed_build_document()
# Get mapping
mapping = ElasticSearchMapping(obj.__class__)
# Add to index
self.es.index(self.es_index, obj.indexed_get_content_type(), doc, id=doc["id"])
# Add document to index
self.es.index(self.es_index, mapping.get_document_type(), mapping.get_document(obj), id=mapping.get_document_id(obj))
def add_bulk(self, obj_list):
# Group all objects by their type
@ -372,29 +455,33 @@ class ElasticSearch(BaseSearch):
if not self.object_can_be_indexed(obj):
continue
# Get object type
obj_type = obj.indexed_get_content_type()
# Get mapping
mapping = ElasticSearchMapping(obj.__class__)
# Get document type
doc_type = mapping.get_document_type()
# If type is currently not in set, add it
if obj_type not in type_set:
type_set[obj_type] = []
if doc_type not in type_set:
type_set[doc_type] = []
# Add object to set
type_set[obj_type].append(obj.indexed_build_document())
# Add document to set
type_set[doc_type].append((mapping.get_document_id(obj), mapping.get_document(obj)))
# Loop through each type and bulk add them
for type_name, type_objects in type_set.items():
for type_name, type_documents in type_set.items():
# Get list of actions
actions = []
for obj in type_objects:
for doc_id, doc in type_documents:
action = {
'_index': self.es_index,
'_type': type_name,
'_id': obj['id'],
'_id': doc_id,
}
action.update(obj)
action.update(doc)
actions.append(action)
yield type_name, len(type_documents)
bulk(self.es, actions)
def delete(self, obj):
@ -402,12 +489,15 @@ class ElasticSearch(BaseSearch):
if not isinstance(obj, Indexed) or not isinstance(obj, models.Model):
return
# Get mapping
mapping = ElasticSearchMapping(obj.__class__)
# Delete document
try:
self.es.delete(
self.es_index,
obj.indexed_get_content_type(),
obj.indexed_get_document_id(),
mapping.get_document_type(),
mapping.get_document_id(obj),
)
except NotFoundError:
pass # Document doesn't exist, ignore this exception

View file

@ -1,3 +1,5 @@
import warnings
from six import string_types
from django.db import models
@ -13,13 +15,13 @@ class Indexed(object):
@classmethod
def indexed_get_content_type(cls):
# Work out content type
content_type = (cls._meta.app_label + "_" + cls.__name__).lower()
content_type = (cls._meta.app_label + '_' + cls.__name__).lower()
# Get parent content type
parent = cls.indexed_get_parent()
if parent:
parent_content_type = parent.indexed_get_content_type()
return parent_content_type + "_" + content_type
return parent_content_type + '_' + content_type
else:
return content_type
@ -31,15 +33,10 @@ class Indexed(object):
return parent.indexed_get_content_type()
else:
# At toplevel, return this content type
return (cls._meta.app_label + "_" + cls.__name__).lower()
return (cls._meta.app_label + '_' + cls.__name__).lower()
@classmethod
def indexed_get_indexed_fields(cls):
# New way
if hasattr(cls, 'search_fields'):
return dict((field.get_attname(cls), field.to_dict(cls)) for field in cls.search_fields)
# Old way
# Get indexed fields for this class as dictionary
indexed_fields = cls.indexed_fields
if isinstance(indexed_fields, dict):
@ -52,7 +49,7 @@ class Indexed(object):
if isinstance(indexed_fields, string_types):
indexed_fields = [indexed_fields]
if isinstance(indexed_fields, list):
indexed_fields = dict((field, dict(type="string")) for field in indexed_fields)
indexed_fields = dict((field, dict(type='string')) for field in indexed_fields)
if not isinstance(indexed_fields, dict):
raise ValueError()
@ -65,32 +62,65 @@ class Indexed(object):
indexed_fields = parent_indexed_fields
return indexed_fields
def indexed_get_document_id(self):
return self.indexed_get_toplevel_content_type() + ":" + str(self.pk)
@classmethod
def get_search_fields(cls):
search_fields = []
def indexed_build_document(self):
# Get content type, indexed fields and id
content_type = self.indexed_get_content_type()
indexed_fields = self.indexed_get_indexed_fields()
doc_id = self.indexed_get_document_id()
if hasattr(cls, 'search_fields'):
search_fields.extend(cls.search_fields)
# Build document
doc = dict(pk=str(self.pk), content_type=content_type, id=doc_id)
for field in indexed_fields.keys():
if hasattr(self, field):
doc[field] = getattr(self, field)
# Backwards compatibility with old indexed_fields setting
# Check if this field is callable
if hasattr(doc[field], "__call__"):
# Call it
doc[field] = doc[field]()
# Get indexed fields
indexed_fields = cls.indexed_get_indexed_fields()
return doc
# Display deprecation warning if indexed_fields has been used
if indexed_fields:
warnings.warn("'indexed_fields' setting is now deprecated."
"Use 'search_fields' instead.", DeprecationWarning)
# Convert them into search fields
for field_name, _config in indexed_fields.items():
# Copy the config to prevent is trashing anything accidentally
config = _config.copy()
# Check if this is a filter field
if config.get('index', None) == 'not_analyzed':
config.pop('index')
search_fields.append(FilterField(field_name, es_extra=config))
continue
# Must be a search field, check for boosting and partial matching
boost = config.pop('boost', None)
partial_match = False
if config.get('analyzer', None) == 'edgengram_analyzer':
partial_match = True
config.pop('analyzer')
# Add the field
search_fields.append(SearchField(field_name, boost=boost, partial_match=partial_match, es_extra=config))
# Remove any duplicate entries into search fields
# We need to take into account that fields can be indexed as both a SearchField and as a FilterField
search_fields_dict = {}
for field in search_fields:
search_fields_dict[(field.field_name, type(field))] = field
search_fields = search_fields_dict.values()
return search_fields
@classmethod
def get_searchable_search_fields(cls):
return filter(lambda field: field.searchable, cls.get_search_fields())
indexed_fields = ()
class BaseField(object):
searchable = False
suffix = ''
def __init__(self, field_name, **kwargs):
self.field_name = field_name
self.kwargs = kwargs
@ -105,41 +135,41 @@ class BaseField(object):
except models.fields.FieldDoesNotExist:
return self.field_name
def to_dict(self, cls):
dic = {
'type': 'string'
}
def get_index_name(self, cls):
return self.get_attname(cls) + self.suffix
if 'es_extra' in self.kwargs:
for key, value in self.kwargs['es_extra'].items():
dic[key] = value
def get_type(self, cls):
if 'type' in self.kwargs:
return self.kwargs['type']
return dic
try:
field = self.get_field(cls)
return field.get_internal_type()
except models.fields.FieldDoesNotExist:
return 'CharField'
def get_value(self, obj):
try:
field = self.get_field(obj.__class__)
return field._get_val_from_obj(obj)
except models.fields.FieldDoesNotExist:
value = getattr(obj, self.field_name, None)
if hasattr(value, '__call__'):
value = value()
return value
def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__, self.field_name)
class SearchField(BaseField):
searchable = True
def __init__(self, field_name, boost=None, partial_match=False, **kwargs):
super(SearchField, self).__init__(field_name, **kwargs)
self.boost = boost
self.partial_match = partial_match
def to_dict(self, cls):
dic = super(SearchField, self).to_dict(cls)
if self.boost and 'boost' not in dic:
dic['boost'] = self.boost
if self.partial_match and 'analyzer' not in dic:
dic['analyzer'] = 'edgengram_analyzer'
return dic
class FilterField(BaseField):
def to_dict(self, cls):
dic = super(FilterField, self).to_dict(cls)
if 'index' not in dic:
dic['index'] = 'not_analyzed'
return dic
suffix = '_filter'

View file

@ -25,13 +25,8 @@ class Command(BaseCommand):
# Loop through objects
for obj in model.objects.all():
# Check if this object has an "object_indexed" function
if hasattr(obj, "object_indexed"):
if obj.object_indexed() is False:
continue
# Get key for this object
key = toplevel_content_type + ":" + str(obj.pk)
key = toplevel_content_type + ':' + str(obj.pk)
# Check if this key already exists
if key in object_set:
@ -62,10 +57,8 @@ class Command(BaseCommand):
# Add objects to index
self.stdout.write("Adding objects")
results = s.add_bulk(object_set.values())
if results:
for result in results:
self.stdout.write(result[0] + ' ' + str(result[1]))
for result in s.add_bulk(object_set.values()):
self.stdout.write(result[0] + ' ' + str(result[1]))
# Refresh index
self.stdout.write("Refreshing index")

View file

@ -11,11 +11,6 @@ from wagtail.wagtailsearch.backends.db import DBSearch
from wagtail.wagtailsearch.backends import InvalidSearchBackendError
# Register wagtailsearch signal handlers
from wagtail.wagtailsearch import register_signal_handlers
register_signal_handlers()
class BackendTests(object):
# To test a specific backend, subclass BackendTests and define self.backend_path.
@ -41,21 +36,25 @@ class BackendTests(object):
testa = models.SearchTest()
testa.title = "Hello World"
testa.save()
self.backend.add(testa)
self.testa = testa
testb = models.SearchTest()
testb.title = "Hello"
testb.live = True
testb.save()
self.backend.add(testb)
testc = models.SearchTestChild()
testc.title = "Hello"
testc.live = True
testc.save()
self.backend.add(testc)
testd = models.SearchTestChild()
testd.title = "World"
testd.save()
self.backend.add(testd)
# Refresh the index
self.backend.refresh_index()
@ -130,6 +129,7 @@ class BackendTests(object):
def test_delete(self):
# Delete one of the objects
self.backend.delete(self.testa)
self.testa.delete()
# Refresh index

View file

@ -0,0 +1 @@
from wagtail.wagtailsearch.urls.frontend import urlpatterns