mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-10 16:24:49 +00:00
Merge remote-tracking branch 'torchbox/master' into page-copy-ui
Conflicts: wagtail/wagtailadmin/forms.py
This commit is contained in:
commit
f837444fe8
97 changed files with 3844 additions and 746 deletions
|
|
@ -1,14 +1,16 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
0.4 (xx.xx.20xx)
|
||||
0.4 (10.07.2014)
|
||||
~~~~~~~~~~~~~~~~
|
||||
* ElasticUtils/pyelasticsearch swapped for elasticsearch-py
|
||||
* Python 3.2, 3.3 and 3.4 support
|
||||
* Added scheduled publishing
|
||||
* Added support for private (password-protected) pages
|
||||
* Added frontend cache invalidator
|
||||
* Added sitemap generator
|
||||
* Added notification preferences
|
||||
* Added a new way to configure searchable/filterable fields on models
|
||||
* Added 'original' as a resizing rule supported by the 'image' tag
|
||||
* Hallo.js updated to version 1.0.4
|
||||
* Snippets are now ordered alphabetically
|
||||
|
|
@ -29,6 +31,14 @@ Changelog
|
|||
* Added init_new_page signal
|
||||
* Added page_published signal
|
||||
* Added copy method to Page to allow copying of pages
|
||||
* Added ``search`` method to ``PageQuerySet``
|
||||
* Added ``get_indexed_objects`` allowing developers to customise which objects get added to the search index
|
||||
* Major refactor of Elasticsearch backend
|
||||
* Use ``match`` instead of ``query_string`` queries
|
||||
* Fields are now indexed in Elasticsearch with their correct type
|
||||
* Filter fields are no longer included in '_all' (in Elasticsearch)
|
||||
* Fields with partial matching are now indexed together into '_partials'
|
||||
|
||||
* Fix: Animated GIFs are now coalesced before resizing
|
||||
* Fix: Wand backend clones images before modifying them
|
||||
* Fix: Admin breadcrumb now positioned correctly on mobile
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
@ -201,6 +204,7 @@ Properties:
|
|||
* status_string
|
||||
* subpage_types
|
||||
* indexed_fields
|
||||
* preview_modes
|
||||
|
||||
Methods:
|
||||
|
||||
|
|
@ -213,8 +217,7 @@ Methods:
|
|||
* get_descendants
|
||||
* get_siblings
|
||||
* search
|
||||
* get_page_modes
|
||||
* show_as_mode
|
||||
* serve_preview
|
||||
|
||||
|
||||
Page Queryset Methods
|
||||
|
|
|
|||
|
|
@ -192,8 +192,9 @@ More control over the ``img`` tag
|
|||
|
||||
Wagtail provides two shorcuts to give greater control over the ``img`` element:
|
||||
|
||||
**1. Adding attributes to the {% image %} tag**
|
||||
|
||||
.. versionadded:: 0.4
|
||||
**Adding attributes to the {% image %} tag**
|
||||
|
||||
Extra attributes can be specified with the syntax ``attribute="value"``:
|
||||
|
||||
|
|
@ -201,25 +202,31 @@ Extra attributes can be specified with the syntax ``attribute="value"``:
|
|||
|
||||
{% image self.photo width-400 class="foo" id="bar" %}
|
||||
|
||||
No validation is performed on attributes add in this way by the developer. It's possible to add `src`, `width`, `height` and `alt` of your own that might conflict with those generated by the tag itself.
|
||||
No validation is performed on attributes added in this way so it's possible to add `src`, `width`, `height` and `alt` of your own that might conflict with those generated by the tag itself.
|
||||
|
||||
|
||||
**Generating the image "as"**
|
||||
**2. Generating the image "as foo" to access individual properties**
|
||||
|
||||
Wagtail can assign the image data to another object using Django's ``as`` syntax:
|
||||
Wagtail can assign the image data to another variable using Django's ``as`` syntax:
|
||||
|
||||
.. code-block:: django
|
||||
|
||||
{% image self.photo width-400 as tmp_photo %}
|
||||
|
||||
<img src="{{ tmp_photo.src }}" width="{{ tmp_photo.width }}"
|
||||
<img src="{{ tmp_photo.url }}" width="{{ tmp_photo.width }}"
|
||||
height="{{ tmp_photo.height }}" alt="{{ tmp_photo.alt }}" class="my-custom-class" />
|
||||
|
||||
|
||||
.. Note::
|
||||
The image property used for the ``src`` attribute is actually ``image.url``, not ``image.src``.
|
||||
|
||||
|
||||
.. versionadded:: 0.4
|
||||
The ``attrs`` shortcut
|
||||
-----------------------
|
||||
|
||||
You can also use the ``attrs`` property as a shorthand to output the ``src``, ``width``, ``height`` and ``alt`` attributes in one go:
|
||||
.. versionadded:: 0.4
|
||||
|
||||
You can also use the ``attrs`` property as a shorthand to output the attributes ``src``, ``width``, ``height`` and ``alt`` in one go:
|
||||
|
||||
.. code-block:: django
|
||||
|
||||
|
|
|
|||
|
|
@ -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.4'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.1'
|
||||
release = '0.4'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
Defining models with the Editing API
|
||||
===========
|
||||
====================================
|
||||
|
||||
.. note::
|
||||
This documentation is currently being written.
|
||||
|
|
@ -251,7 +251,7 @@ Field Customization
|
|||
|
||||
By adding CSS classnames to your panel definitions or adding extra parameters to your field definitions, you can control much of how your fields will display in the Wagtail page editing interface. Wagtail's page editing interface takes much of its behavior from Django's admin, so you may find many options for customization covered there. (See `Django model field reference`_ ).
|
||||
|
||||
.. _Django model field reference:https://docs.djangoproject.com/en/dev/ref/models/fields/
|
||||
.. _Django model field reference: https://docs.djangoproject.com/en/dev/ref/models/fields/
|
||||
|
||||
|
||||
Full-Width Input
|
||||
|
|
@ -374,7 +374,7 @@ For more on ``django-modelcluster``, visit `the django-modelcluster github proje
|
|||
Extending the WYSIWYG Editor (hallo.js)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
To inject javascript into the Wagtail page editor, see the :ref:`insert_editor_js` hook. Once you have the hook in place and your hallo.js plugin loads into the Wagtail page editor, use the following Javascript to register the plugin with hallo.js.
|
||||
To inject javascript into the Wagtail page editor, see the :ref:`insert_editor_js <insert_editor_js>` hook. Once you have the hook in place and your hallo.js plugin loads into the Wagtail page editor, use the following Javascript to register the plugin with hallo.js.
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
|
|
@ -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``
|
||||
|
|
@ -565,7 +582,8 @@ Where ``'hook'`` is one of the following hook strings and ``function`` is a func
|
|||
.. _construct_whitelister_element_rules:
|
||||
|
||||
``construct_whitelister_element_rules``
|
||||
.. versionadded:: 0.4
|
||||
.. versionadded:: 0.4
|
||||
|
||||
Customise the rules that define which HTML elements are allowed in rich text areas. By default only a limited set of HTML elements and attributes are whitelisted - all others are stripped out. The callables passed into this hook must return a dict, which maps element names to handler functions that will perform some kind of manipulation of the element. These handler functions receive the element as a `BeautifulSoup <http://www.crummy.com/software/BeautifulSoup/bs4/doc/>`_ Tag object.
|
||||
|
||||
The ``wagtail.wagtailcore.whitelist`` module provides a few helper functions to assist in defining these handlers: ``allow_without_attributes``, a handler which preserves the element but strips out all of its attributes, and ``attribute_rule`` which accepts a dict specifying how to handle each attribute, and returns a handler function. This dict will map attribute names to either True (indicating that the attribute should be kept), False (indicating that it should be dropped), or a callable (which takes the initial attribute value and returns either a final value for the attribute, or None to drop the attribute).
|
||||
|
|
@ -593,6 +611,7 @@ On loading, Wagtail will search for any app with the file ``image_formats.py`` a
|
|||
As an example, add a "thumbnail" format:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# image_formats.py
|
||||
from wagtail.wagtailimages.formats import Format, register_image_format
|
||||
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ Wagtail allows you to manage all of your documents and images through their own
|
|||
|
||||
documents
|
||||
images
|
||||
snippets
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
Snippets
|
||||
~~~~~~~~
|
||||
|
||||
.. note::
|
||||
Documentation currently incomplete and in draft status
|
||||
|
||||
.. UNSURE HOW TO WRITE THIS AS THE ADVERT EXAMPLE IN WAGTAIL DEMO IS NOT A PARTICULARLY HELPFUL USE CASE.
|
||||
|
||||
When creating a page on a website, it is a common occurance to want to add in a piece of content that already exists on another page. An example of this would be a person's contact details, or an advert that you want to simply show on every page of your site, without having to manually apply it.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
.. _frontend_cache_purging:
|
||||
|
||||
Frontend cache purging
|
||||
======================
|
||||
|
||||
|
|
@ -100,3 +102,18 @@ Let's take the the above BlogIndexPage as an example. We need to register a sign
|
|||
@register(pre_delete, sender=BlogPage)
|
||||
def blog_deleted_handler(instance):
|
||||
blog_page_changed(instance)
|
||||
|
||||
|
||||
Purging individual URLs
|
||||
-----------------------
|
||||
|
||||
``wagtail.contrib.wagtailfrontendcache.utils`` provides another utils function called ``purge_url_from_cache``. As the name suggests, this purges an individual URL from the cache.
|
||||
|
||||
For example, this could be useful for purging a single page of blogs:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from wagtail.contrib.wagtailfrontendcache.utils import purge_url_from_cache
|
||||
|
||||
# Purge the first page of the blog index
|
||||
purge_url_from_cache(blog_index.url + '?page=1')
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ Once you've experimented with the demo app and are ready to build your pages via
|
|||
On OS X
|
||||
~~~~~~~
|
||||
|
||||
Install `pip <http://pip.readthedocs.org/en/latest/installing.html>`_ and `virtualenvwrapper <http://virtualenvwrapper.readthedocs.org/en/latest/>`_ if you don't have them already. Then, in your terminal::
|
||||
Install `pip <http://pip.readthedocs.org/en/latest/installing.html>`__ and `virtualenvwrapper <http://virtualenvwrapper.readthedocs.org/en/latest/>`_ if you don't have them already. Then, in your terminal::
|
||||
|
||||
mkvirtualenv wagtaildemo
|
||||
git clone https://github.com/torchbox/wagtaildemo.git
|
||||
|
|
@ -75,10 +75,10 @@ Wagtail instance available as the basis for your new site:
|
|||
./manage.py runserver 0.0.0.0:8000
|
||||
|
||||
- This will make the app accessible on the host machine as
|
||||
`localhost:8111 <http://localhost:8111>`_ - you can access the Wagtail admin
|
||||
interface at `localhost:8111/admin <http://localhost:8111/admin>`_. The codebase
|
||||
is located on the host machine, exported to the VM as a shared folder; code
|
||||
editing and Git operations will generally be done on the host.
|
||||
`localhost:8111 <http://localhost:8111>`_ - you can access the Wagtail admin
|
||||
interface at `localhost:8111/admin <http://localhost:8111/admin>`_. The codebase
|
||||
is located on the host machine, exported to the VM as a shared folder; code
|
||||
editing and Git operations will generally be done on the host.
|
||||
|
||||
Using Docker
|
||||
~~~~~~~~~~~~
|
||||
|
|
@ -101,7 +101,7 @@ use the following steps:
|
|||
Required dependencies
|
||||
=====================
|
||||
|
||||
- `pip <https://github.com/pypa/pip>`_
|
||||
- `pip <https://github.com/pypa/pip>`__
|
||||
- `libjpeg <http://ijg.org/>`_
|
||||
- `libxml2 <http://xmlsoft.org/>`_
|
||||
- `libxslt <http://xmlsoft.org/XSLT/>`_
|
||||
|
|
|
|||
|
|
@ -13,15 +13,19 @@ It supports Django 1.6.2+ on Python 2.6, 2.7, 3.2, 3.3 and 3.4. Django 1.7 suppo
|
|||
building_your_site/index
|
||||
editing_api
|
||||
snippets
|
||||
wagtail_search
|
||||
search/index
|
||||
form_builder
|
||||
model_recipes
|
||||
advanced_topics
|
||||
deploying
|
||||
performance
|
||||
private_pages
|
||||
static_site_generation
|
||||
frontend_cache_purging
|
||||
sitemap_generation
|
||||
management_commands
|
||||
contributing
|
||||
support
|
||||
roadmap
|
||||
editor_manual/index
|
||||
releases/index
|
||||
|
|
|
|||
|
|
@ -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
|
||||
----------
|
||||
|
||||
|
|
@ -25,10 +36,13 @@ This command moves a selection of pages from one section of the tree to another.
|
|||
Options:
|
||||
|
||||
- **from**
|
||||
This is the **id** of the page to move pages from. All descendants of this page will be moved to the destination. After the operation is complete, this page will have no children.
|
||||
This is the **id** of the page to move pages from. All descendants of this page will be moved to the destination. After the operation is complete, this page will have no children.
|
||||
|
||||
- **to**
|
||||
This is the **id** of the page to move pages 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
|
||||
----------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ Consider this example from the Wagtail demo site's ``models.py``, which serves a
|
|||
With this strategy, you could use Django or Python utilities to render your model in JSON or XML or any other format you'd like.
|
||||
|
||||
|
||||
.. _overriding_route_method:
|
||||
|
||||
Adding Endpoints with Custom route() Methods
|
||||
--------------------------------------------
|
||||
|
||||
|
|
@ -58,8 +60,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 +69,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 +87,7 @@ First, ``models.py``:
|
|||
.. code-block:: python
|
||||
|
||||
from django.shortcuts import render
|
||||
from wagtail.wagtailcore.url_routing import RouteResult
|
||||
|
||||
...
|
||||
|
||||
|
|
@ -89,15 +95,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 +118,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 +128,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:
|
||||
|
||||
|
|
@ -190,11 +207,11 @@ Load Alternate Templates by Overriding get_template()
|
|||
|
||||
|
||||
|
||||
Page Modes
|
||||
----------
|
||||
Preview Modes
|
||||
-------------
|
||||
|
||||
get_page_modes
|
||||
show_as_mode
|
||||
preview_modes
|
||||
serve_preview
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
64
docs/private_pages.rst
Normal 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'
|
||||
205
docs/releases/0.4.rst
Normal file
205
docs/releases/0.4.rst
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
=========================
|
||||
Wagtail 0.4 release notes
|
||||
=========================
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
|
||||
What's 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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
It's now possible to perform searches with Elasticsearch on ``PageQuerySet`` objects:
|
||||
|
||||
>>> 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 receive 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 ``search`` method 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
|
||||
|
||||
|
||||
Search
|
||||
------
|
||||
|
||||
* Added a new way to configure searchable/filterable fields on models
|
||||
* Added ``get_indexed_objects`` allowing developers to customise which objects get added to the search index
|
||||
* Major refactor of Elasticsearch backend
|
||||
* Use ``match`` instead of ``query_string`` queries
|
||||
* Fields are now indexed in Elasticsearch with their correct type
|
||||
* Filter fields are no longer included in '_all'
|
||||
* Fields with partial matching are now indexed together into '_partials'
|
||||
|
||||
|
||||
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, for Wagtail developers
|
||||
|
||||
|
||||
Bug fixes
|
||||
~~~~~~~~~
|
||||
|
||||
* Animated GIFs are now coalesced before resizing
|
||||
* The Wand backend clones images before modifying them
|
||||
* The admin breadcrumb is now positioned correctly on mobile
|
||||
* The 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
|
||||
* It is 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.
|
||||
|
||||
|
||||
Addition of ``expired`` column may break old data migrations involving pages
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The scheduled publishing mechanism adds an ``expired`` field to wagtailcore.Page, defaulting to False. Any application code working with Page objects should be unaffected, but any code that creates page records using direct SQL, or within existing South migrations using South's frozen ORM, will fail as this code will be unaware of the ``expired`` database column. To fix a South migration that fails in this way, add the following line to the ``'wagtailcore.page'`` entry at the bottom of the migration file:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
'expired': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
|
||||
|
||||
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``
|
||||
|
||||
The old names will continue to work, but output a ``DeprecationWarning`` - you are advised to update any ``{% load %}`` tags in your templates to refer to the new names.
|
||||
|
||||
|
||||
New search field configuration format
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``indexed_fields`` is now deprecated and has been replaced by a new search field configuration format called ``search_fields``. See :ref:`wagtailsearch_for_python_developers` for how to define a ``search_fields`` property on your models.
|
||||
|
||||
|
||||
``Page.route`` method should now return a ``RouteResult``
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Previously, the ``route`` method called ``serve`` and returned an ``HttpResponse`` object. This has now been split up so ``serve`` is called separately and ``route`` must now return a RouteResult object.
|
||||
|
||||
If you are overriding ``Page.route`` on any of your page models, you will need to update the method to return a ``RouteResult`` object. The old method of returning an ``HttpResponse`` will continue to work, but this will throw a ``DeprecationWarning`` and bypass the ``before_serve_page`` hook, which means in particular that :ref:`private_pages` will not work on those page types. See :ref:`overriding_route_method`.
|
||||
|
||||
|
||||
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)``
|
||||
27
docs/releases/0.5.rst
Normal file
27
docs/releases/0.5.rst
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
==========================================
|
||||
Wagtail 0.5 release notes - IN DEVELOPMENT
|
||||
==========================================
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
:depth: 1
|
||||
|
||||
|
||||
Whats new
|
||||
=========
|
||||
|
||||
|
||||
Minor features
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
Bug fixes
|
||||
~~~~~~~~~
|
||||
|
||||
|
||||
Backwards incompatible changes
|
||||
==============================
|
||||
|
||||
|
||||
Deprecated features
|
||||
===================
|
||||
8
docs/releases/index.rst
Normal file
8
docs/releases/index.rst
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
Release notes
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
0.5
|
||||
0.4
|
||||
66
docs/search/backends.rst
Normal file
66
docs/search/backends.rst
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
|
||||
.. _wagtailsearch_backends:
|
||||
|
||||
========
|
||||
Backends
|
||||
========
|
||||
|
||||
|
||||
Wagtail can degrade to a database-backed text search, but we strongly recommend `Elasticsearch`_.
|
||||
|
||||
.. _Elasticsearch: http://www.elasticsearch.org/
|
||||
|
||||
|
||||
.. _wagtailsearch_backends_database:
|
||||
|
||||
Database Backend
|
||||
================
|
||||
|
||||
The default DB search backend uses Django's ``__icontains`` filter.
|
||||
|
||||
|
||||
Elasticsearch Backend
|
||||
=====================
|
||||
|
||||
Prerequisites are the Elasticsearch service itself and, via pip, the `elasticsearch-py`_ package:
|
||||
|
||||
.. code-block:: guess
|
||||
|
||||
pip install elasticsearch
|
||||
|
||||
.. note::
|
||||
If you are using Elasticsearch < 1.0, install elasticsearch-py version 0.4.5: ```pip install elasticsearch==0.4.5```
|
||||
|
||||
The backend is configured in settings:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
WAGTAILSEARCH_BACKENDS = {
|
||||
'default': {
|
||||
'BACKEND': 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch',
|
||||
'URLS': ['http://localhost:9200'],
|
||||
'INDEX': 'wagtail',
|
||||
'TIMEOUT': 5,
|
||||
'FORCE_NEW': False,
|
||||
}
|
||||
}
|
||||
|
||||
Other than ``BACKEND`` the keys are optional and default to the values shown. ``FORCE_NEW`` is used by elasticsearch-py. In addition, any other keys are passed directly to the Elasticsearch constructor as case-sensitive keyword arguments (e.g. ``'max_retries': 1``).
|
||||
|
||||
If you prefer not to run an Elasticsearch server in development or production, there are many hosted services available, including `Searchly`_, who offer a free account suitable for testing and development. To use Searchly:
|
||||
|
||||
- Sign up for an account at `dashboard.searchly.com/users/sign\_up`_
|
||||
- Use your Searchly dashboard to create a new index, e.g. 'wagtaildemo'
|
||||
- Note the connection URL from your Searchly dashboard
|
||||
- Configure ``URLS`` and ``INDEX`` in the Elasticsearch entry in ``WAGTAILSEARCH_BACKENDS``
|
||||
- Run ``./manage.py update_index``
|
||||
|
||||
.. _elasticsearch-py: http://elasticsearch-py.readthedocs.org
|
||||
.. _Searchly: http://www.searchly.com/
|
||||
.. _dashboard.searchly.com/users/sign\_up: https://dashboard.searchly.com/users/sign_up
|
||||
|
||||
|
||||
Rolling Your Own
|
||||
================
|
||||
|
||||
Wagtail search backends implement the interface defined in ``wagtail/wagtail/wagtailsearch/backends/base.py``. At a minimum, the backend's ``search()`` method must return a collection of objects or ``model.objects.none()``. For a fully-featured search backend, examine the Elasticsearch backend code in ``elasticsearch.py``.
|
||||
41
docs/search/editors_picks.rst
Normal file
41
docs/search/editors_picks.rst
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
|
||||
.. _editors-picks:
|
||||
|
||||
|
||||
Editors picks
|
||||
=============
|
||||
|
||||
Editor's Picks are a way of explicitly linking relevant content to search terms, so results pages can contain curated content instead of being at the mercy of the search algorithm. In a template using the search results view, editor's picks can be accessed through the variable ``query.editors_picks``. To include editor's picks in your search results template, use the following properties.
|
||||
|
||||
``query.editors_picks.all``
|
||||
This gathers all of the editor's picks objects relating to the current query, in order according to their sort order in the Wagtail admin. You can then iterate through them using a ``{% for ... %}`` loop. Each editor's pick object provides these properties:
|
||||
|
||||
``editors_pick.page``
|
||||
The page object associated with the pick. Use ``{% pageurl editors_pick.page %}`` to generate a URL or provide other properties of the page object.
|
||||
|
||||
``editors_pick.description``
|
||||
The description entered when choosing the pick, perhaps explaining why the page is relevant to the search terms.
|
||||
|
||||
Putting this all together, a block of your search results template displaying editor's Picks might look like this:
|
||||
|
||||
.. code-block:: django
|
||||
|
||||
{% with query.editors_picks.all as editors_picks %}
|
||||
{% if editors_picks %}
|
||||
<div class="well">
|
||||
<h3>Editors picks</h3>
|
||||
<ul>
|
||||
{% for editors_pick in editors_picks %}
|
||||
<li>
|
||||
<h4>
|
||||
<a href="{% pageurl editors_pick.page %}">
|
||||
{{ editors_pick.page.title }}
|
||||
</a>
|
||||
</h4>
|
||||
<p>{{ editors_pick.description|safe }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
152
docs/search/for_python_developers.rst
Normal file
152
docs/search/for_python_developers.rst
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
|
||||
.. _wagtailsearch_for_python_developers:
|
||||
|
||||
|
||||
=====================
|
||||
For Python developers
|
||||
=====================
|
||||
|
||||
|
||||
Basic usage
|
||||
===========
|
||||
|
||||
All searches are performed on Django QuerySets. Wagtail provides a ``search`` method on the queryset for all page models:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Search future EventPages
|
||||
>>> from wagtail.wagtailcore.models import EventPage
|
||||
>>> EventPage.objects.filter(date__gt=timezone.now()).search("Hello world!")
|
||||
|
||||
|
||||
All methods of ``PageQuerySet`` are supported by wagtailsearch:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Search all live EventPages that are under the events index
|
||||
>>> EventPage.objects.live().descendant_of(events_index).search("Event")
|
||||
[<EventPage: Event 1>, <EventPage: Event 2>]
|
||||
|
||||
|
||||
Indexing extra fields
|
||||
=====================
|
||||
|
||||
Fields need to be explicitly added to the search configuration in order for you to be able to search/filter on them.
|
||||
|
||||
You can add new fields to the search index by overriding the ``search_fields`` property and appending a list of extra ``SearchField``/``FilterField`` objects to it.
|
||||
|
||||
The default value of ``search_fields`` (as set in ``Page``) indexes the ``title`` field as a ``SearchField`` and some other generally useful fields as ``FilterField`` rules.
|
||||
|
||||
|
||||
Quick example
|
||||
-------------
|
||||
|
||||
This creates an ``EventPage`` model with two fields ``description`` and ``date``. ``description`` is indexed as a ``SearchField`` and ``date`` is indexed as a ``FilterField``
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from wagtail.wagtailsearch import indexed
|
||||
|
||||
class EventPage(Page):
|
||||
description = models.TextField()
|
||||
date = models.DateField()
|
||||
|
||||
search_fields = Page.search_fields + ( # Inherit search_fields from Page
|
||||
indexed.SearchField('description'),
|
||||
indexed.FilterField('date'),
|
||||
)
|
||||
|
||||
|
||||
# Get future events which contain the string "Christmas" in the title or description
|
||||
>>> EventPage.objects.filter(date__gt=timezone.now()).search("Christmas")
|
||||
|
||||
|
||||
``indexed.SearchField``
|
||||
-----------------------
|
||||
|
||||
These are added to the search index and are used for performing full-text searches on your models. These would usually be text fields.
|
||||
|
||||
|
||||
Options
|
||||
```````
|
||||
|
||||
- **partial_match** (boolean) - Setting this to true allows results to be matched on parts of words. For example, this is set on the title field by default so a page titled "Hello World!" will be found if the user only types "Hel" into the search box.
|
||||
- **boost** (number) - This allows you to set fields as being more important than others. Setting this to a high number on a field will make pages with matches in that field to be ranked higher. By default, this is set to 100 on the title field and 1 on all other fields.
|
||||
- **es_extra** (dict) - This field is to allow the developer to set or override any setting on the field in the ElasticSearch mapping. Use this if you want to make use of any ElasticSearch features that are not yet supported in Wagtail.
|
||||
|
||||
|
||||
``indexed.FilterField``
|
||||
-----------------------
|
||||
|
||||
These are added to the search index but are not used for full-text searches. Instead, they allow you to run filters on your search results.
|
||||
|
||||
|
||||
Indexing callables and other attributes
|
||||
---------------------------------------
|
||||
|
||||
.. note::
|
||||
|
||||
This is not supported in the :ref:`wagtailsearch_backends_database`
|
||||
|
||||
|
||||
Search/filter fields do not need to be Django fields, they could be any method or attribute on your class.
|
||||
|
||||
One use for this is indexing ``get_*_display`` methods Django creates automatically for fields with choices.
|
||||
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from wagtail.wagtailsearch import indexed
|
||||
|
||||
class EventPage(Page):
|
||||
IS_PRIVATE_CHOICES = (
|
||||
(False, "Public"),
|
||||
(True, "Private"),
|
||||
)
|
||||
|
||||
is_private = models.BooleanField(choices=IS_PRIVATE_CHOICES)
|
||||
|
||||
search_fields = Page.search_fields + (
|
||||
# Index the human-readable string for searching
|
||||
indexed.SearchField('get_is_private_display'),
|
||||
|
||||
# Index the boolean value for filtering
|
||||
indexed.FilterField('is_private'),
|
||||
)
|
||||
|
||||
|
||||
Indexing non-page models
|
||||
========================
|
||||
|
||||
Any Django model can be indexed and searched.
|
||||
|
||||
To do this, inherit from ``indexed.Indexed`` and add some ``search_fields`` to the model.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from wagtail.wagtailsearch import indexed
|
||||
|
||||
class Book(models.Model, indexed.Indexed):
|
||||
title = models.CharField(max_length=255)
|
||||
genre = models.CharField(max_length=255, choices=GENRE_CHOICES)
|
||||
author = models.ForeignKey(Author)
|
||||
published_date = models.DateTimeField()
|
||||
|
||||
search_fields = (
|
||||
indexed.SearchField('title', partial_match=True, boost=10),
|
||||
indexed.SearchField('get_genre_display'),
|
||||
|
||||
indexed.FilterField('genre'),
|
||||
indexed.FilterField('author'),
|
||||
indexed.FilterField('published_date'),
|
||||
)
|
||||
|
||||
# As this model doesn't have a search method in its QuerySet, we have to call search directly on the backend
|
||||
>>> from wagtail.wagtailsearch.backends import get_search_backend
|
||||
>>> s = get_search_backend()
|
||||
|
||||
# Run a search for a book by Roald Dahl
|
||||
>>> roald_dahl = Author.objects.get(name="Roald Dahl")
|
||||
>>> s.search("chocolate factory", Book.objects.filter(author=roald_dahl))
|
||||
[<Book: Charlie and the chocolate factory>]
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
|
||||
.. _search:
|
||||
.. _wagtailsearch_frontend_views:
|
||||
|
||||
Search
|
||||
======
|
||||
|
||||
Wagtail provides a comprehensive and extensible search interface. In addition, it provides ways to promote search results through "Editor's Picks." Wagtail also collects simple statistics on queries made through the search interface.
|
||||
Frontend views
|
||||
==============
|
||||
|
||||
|
||||
Default Page Search
|
||||
-------------------
|
||||
|
|
@ -71,43 +71,6 @@ The search view provides a context with a few useful variables.
|
|||
``query``
|
||||
A Wagtail ``Query`` object matching the terms. The ``Query`` model provides several class methods for viewing the statistics of all queries, but exposes only one property for single objects, ``query.hits``, which tracks the number of time the search string has been used over the lifetime of the site. ``Query`` also joins to the Editor's Picks functionality though ``query.editors_picks``. See :ref:`editors-picks`.
|
||||
|
||||
Editor's Picks
|
||||
--------------
|
||||
|
||||
Editor's Picks are a way of explicitly linking relevant content to search terms, so results pages can contain curated content instead of being at the mercy of the search algorithm. In a template using the search results view, editor's picks can be accessed through the variable ``query.editors_picks``. To include editor's picks in your search results template, use the following properties.
|
||||
|
||||
``query.editors_picks.all``
|
||||
This gathers all of the editor's picks objects relating to the current query, in order according to their sort order in the Wagtail admin. You can then iterate through them using a ``{% for ... %}`` loop. Each editor's pick object provides these properties:
|
||||
|
||||
``editors_pick.page``
|
||||
The page object associated with the pick. Use ``{% pageurl editors_pick.page %}`` to generate a URL or provide other properties of the page object.
|
||||
|
||||
``editors_pick.description``
|
||||
The description entered when choosing the pick, perhaps explaining why the page is relevant to the search terms.
|
||||
|
||||
Putting this all together, a block of your search results template displaying editor's Picks might look like this:
|
||||
|
||||
.. code-block:: django
|
||||
|
||||
{% with query.editors_picks.all as editors_picks %}
|
||||
{% if editors_picks %}
|
||||
<div class="well">
|
||||
<h3>Editors picks</h3>
|
||||
<ul>
|
||||
{% for editors_pick in editors_picks %}
|
||||
<li>
|
||||
<h4>
|
||||
<a href="{% pageurl editors_pick.page %}">
|
||||
{{ editors_pick.page.title }}
|
||||
</a>
|
||||
</h4>
|
||||
<p>{{ editors_pick.description|safe }}</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
Asynchronous Search with JSON and AJAX
|
||||
--------------------------------------
|
||||
|
|
@ -142,30 +105,30 @@ Finally, we'll use JQuery to make the asynchronous requests and handle the inter
|
|||
// when there's something in the input box, make the query
|
||||
searchBox.on('input', function() {
|
||||
if( searchBox.val() == ''){
|
||||
resultsBox.html('');
|
||||
return;
|
||||
resultsBox.html('');
|
||||
return;
|
||||
}
|
||||
// make the request to the Wagtail JSON search view
|
||||
$.ajax({
|
||||
url: wagtailJSONSearchURL + "?q=" + searchBox.val(),
|
||||
dataType: "json"
|
||||
url: wagtailJSONSearchURL + "?q=" + searchBox.val(),
|
||||
dataType: "json"
|
||||
})
|
||||
.done(function(data) {
|
||||
console.log(data);
|
||||
if( data == undefined ){
|
||||
resultsBox.html('');
|
||||
return;
|
||||
}
|
||||
// we're in business! let's format the results
|
||||
var htmlOutput = '';
|
||||
data.forEach(function(element, index, array){
|
||||
htmlOutput += '<p><a href="' + element.url + '">' + element.title + '</a></p>';
|
||||
});
|
||||
// and display them
|
||||
resultsBox.html(htmlOutput);
|
||||
console.log(data);
|
||||
if( data == undefined ){
|
||||
resultsBox.html('');
|
||||
return;
|
||||
}
|
||||
// we're in business! let's format the results
|
||||
var htmlOutput = '';
|
||||
data.forEach(function(element, index, array){
|
||||
htmlOutput += '<p><a href="' + element.url + '">' + element.title + '</a></p>';
|
||||
});
|
||||
// and display them
|
||||
resultsBox.html(htmlOutput);
|
||||
})
|
||||
.error(function(data){
|
||||
console.log(data);
|
||||
console.log(data);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -178,12 +141,12 @@ Results are returned as a JSON object with this structure:
|
|||
{
|
||||
[
|
||||
{
|
||||
title: "Lumpy Space Princess",
|
||||
url: "/oh-my-glob/"
|
||||
title: "Lumpy Space Princess",
|
||||
url: "/oh-my-glob/"
|
||||
},
|
||||
{
|
||||
title: "Lumpy Space",
|
||||
url: "/no-smooth-posers/"
|
||||
title: "Lumpy Space",
|
||||
url: "/no-smooth-posers/"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
|
@ -199,67 +162,8 @@ The AJAX interface uses the same view as the normal HTML search, ``wagtailsearch
|
|||
|
||||
In this template, you'll have access to the same context variables provided to the HTML template. You could provide a template in JSON format with extra properties, such as ``query.hits`` and editor's picks, or render an HTML snippet that can go directly into your results ``<div>``. If you need more flexibility, such as multiple formats/templates based on differing requests, you can set up a custom search view.
|
||||
|
||||
.. _editors-picks:
|
||||
|
||||
Custom Search Views
|
||||
-------------------
|
||||
|
||||
Indexing Custom Fields & Custom Search Views
|
||||
--------------------------------------------
|
||||
|
||||
This functionality is still under active development to provide a streamlined interface, but take a look at ``wagtail/wagtail/wagtailsearch/views/frontend.py`` if you are interested in coding custom search views.
|
||||
|
||||
|
||||
Search Backends
|
||||
---------------
|
||||
|
||||
Wagtail can degrade to a database-backed text search, but we strongly recommend `Elasticsearch`_.
|
||||
|
||||
.. _Elasticsearch: http://www.elasticsearch.org/
|
||||
|
||||
|
||||
Default DB Backend
|
||||
``````````````````
|
||||
The default DB search backend uses Django's ``__icontains`` filter.
|
||||
|
||||
|
||||
Elasticsearch Backend
|
||||
`````````````````````
|
||||
Prerequisites are the Elasticsearch service itself and, via pip, the `elasticsearch-py`_ package:
|
||||
|
||||
.. code-block:: guess
|
||||
|
||||
pip install elasticsearch
|
||||
|
||||
.. note::
|
||||
If you are using Elasticsearch < 1.0, install elasticsearch-py version 0.4.5: ```pip install elasticsearch==0.4.5```
|
||||
|
||||
The backend is configured in settings:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
WAGTAILSEARCH_BACKENDS = {
|
||||
'default': {
|
||||
'BACKEND': 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch',
|
||||
'URLS': ['http://localhost:9200'],
|
||||
'INDEX': 'wagtail',
|
||||
'TIMEOUT': 5,
|
||||
'FORCE_NEW': False,
|
||||
}
|
||||
}
|
||||
|
||||
Other than ``BACKEND`` the keys are optional and default to the values shown. ``FORCE_NEW`` is used by elasticsearch-py. In addition, any other keys are passed directly to the Elasticsearch constructor as case-sensitive keyword arguments (e.g. ``'max_retries': 1``).
|
||||
|
||||
If you prefer not to run an Elasticsearch server in development or production, there are many hosted services available, including `Searchly`_, who offer a free account suitable for testing and development. To use Searchly:
|
||||
|
||||
- Sign up for an account at `dashboard.searchly.com/users/sign\_up`_
|
||||
- Use your Searchly dashboard to create a new index, e.g. 'wagtaildemo'
|
||||
- Note the connection URL from your Searchly dashboard
|
||||
- Configure ``URLS`` and ``INDEX`` in the Elasticsearch entry in ``WAGTAILSEARCH_BACKENDS``
|
||||
- Run ``./manage.py update_index``
|
||||
|
||||
.. _elasticsearch-py: http://elasticsearch-py.readthedocs.org
|
||||
.. _Searchly: http://www.searchly.com/
|
||||
.. _dashboard.searchly.com/users/sign\_up: https://dashboard.searchly.com/users/sign_up
|
||||
|
||||
Rolling Your Own
|
||||
````````````````
|
||||
Wagtail search backends implement the interface defined in ``wagtail/wagtail/wagtailsearch/backends/base.py``. At a minimum, the backend's ``search()`` method must return a collection of objects or ``model.objects.none()``. For a fully-featured search backend, examine the Elasticsearch backend code in ``elasticsearch.py``.
|
||||
This functionality is still under active development to provide a streamlined interface, but take a look at ``wagtail/wagtail/wagtailsearch/views/frontend.py`` if you are interested in coding custom search views.
|
||||
17
docs/search/index.rst
Normal file
17
docs/search/index.rst
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
.. _wagtailsearch:
|
||||
|
||||
|
||||
Search
|
||||
======
|
||||
|
||||
Wagtail provides a comprehensive and extensible search interface. In addition, it provides ways to promote search results through "Editor's Picks." Wagtail also collects simple statistics on queries made through the search interface.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
for_python_developers
|
||||
frontend_views
|
||||
editors_picks
|
||||
backends
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Configuring Django for Wagtail
|
||||
==============================
|
||||
|
||||
To install Wagtail completely from scratch, create a new Django project and an app within that project. For instructions on these tasks, see `Writing your first Django app`_. Your project directory will look like the following::
|
||||
To install Wagtail completely from scratch, create a new Django project and an app within that project. For instructions on these tasks, see `Writing your first Django app <https://docs.djangoproject.com/en/dev/intro/tutorial01/>`_. Your project directory will look like the following::
|
||||
|
||||
myproject/
|
||||
myproject/
|
||||
|
|
@ -19,13 +19,7 @@ To install Wagtail completely from scratch, create a new Django project and an a
|
|||
views.py
|
||||
manage.py
|
||||
|
||||
From your app directory, you can safely remove ``admin.py`` and ``views.py``, since Wagtail will provide this functionality for your models. Configuring Django to load Wagtail involves adding modules and variables to ``settings.py`` and urlconfs to ``urls.py``. For a more complete view of what's defined in these files, see `Django Settings`_ and `Django URL Dispatcher`_.
|
||||
|
||||
.. _Writing your first Django app: https://docs.djangoproject.com/en/dev/intro/tutorial01/
|
||||
|
||||
.. _Django Settings: https://docs.djangoproject.com/en/dev/topics/settings/
|
||||
|
||||
.. _Django URL Dispatcher: https://docs.djangoproject.com/en/dev/topics/http/urls/
|
||||
From your app directory, you can safely remove ``admin.py`` and ``views.py``, since Wagtail will provide this functionality for your models. Configuring Django to load Wagtail involves adding modules and variables to ``settings.py`` and urlconfs to ``urls.py``. For a more complete view of what's defined in these files, see `Django Settings <https://docs.djangoproject.com/en/dev/topics/settings/>`__ and `Django URL Dispatcher <https://docs.djangoproject.com/en/dev/topics/http/urls/>`_.
|
||||
|
||||
What follows is a settings reference which skips many boilerplate Django settings. If you just want to get your Wagtail install up quickly without fussing with settings at the moment, see :ref:`complete_example_config`.
|
||||
|
||||
|
|
@ -246,6 +240,16 @@ Email Notifications
|
|||
Wagtail sends email notifications when content is submitted for moderation, and when the content is accepted or rejected. This setting lets you pick which email address these automatic notifications will come from. If omitted, Django will fall back to using the ``DEFAULT_FROM_EMAIL`` variable if set, and ``webmaster@localhost`` if not.
|
||||
|
||||
|
||||
Private Pages
|
||||
-------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
PASSWORD_REQUIRED_TEMPLATE = 'myapp/password_required.html'
|
||||
|
||||
This is the path to the Django template which will be used to display the "password required" form when a user accesses a private page. For more details, see the :ref:`private_pages` documentation.
|
||||
|
||||
|
||||
Other Django Settings Used by Wagtail
|
||||
-------------------------------------
|
||||
|
||||
|
|
@ -266,9 +270,7 @@ Other Django Settings Used by Wagtail
|
|||
TEMPLATE_CONTEXT_PROCESSORS
|
||||
USE_I18N
|
||||
|
||||
For information on what these settings do, see `Django Settings`_.
|
||||
|
||||
.. _Django Settings: https://docs.djangoproject.com/en/dev/ref/settings/
|
||||
For information on what these settings do, see `Django Settings <https://docs.djangoproject.com/en/dev/ref/settings/>`__.
|
||||
|
||||
|
||||
Search Signal Handlers
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
.. _sitemap_generation:
|
||||
|
||||
Sitemap generation
|
||||
==================
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
8
setup.py
8
setup.py
|
|
@ -25,9 +25,9 @@ PY3 = sys.version_info[0] == 3
|
|||
install_requires = [
|
||||
"Django>=1.6.2,<1.7",
|
||||
"South>=0.8.4",
|
||||
"django-compressor>=1.3",
|
||||
"django-libsass>=0.1",
|
||||
"django-modelcluster>=0.1",
|
||||
"django-compressor>=1.4",
|
||||
"django-libsass>=0.2",
|
||||
"django-modelcluster>=0.3",
|
||||
"django-taggit==0.11.2",
|
||||
"django-treebeard==2.0",
|
||||
"Pillow>=2.3.0",
|
||||
|
|
@ -47,7 +47,7 @@ if not PY3:
|
|||
|
||||
setup(
|
||||
name='wagtail',
|
||||
version='0.3.1',
|
||||
version='0.4',
|
||||
description='A Django content management system focused on flexibility and user experience',
|
||||
author='Matthew Westcott',
|
||||
author_email='matthew.westcott@torchbox.com',
|
||||
|
|
|
|||
|
|
@ -24,12 +24,17 @@ class CustomHTTPAdapter(HTTPAdapter):
|
|||
return super(CustomHTTPAdapter, self).get_connection(self.cache_url, proxies)
|
||||
|
||||
|
||||
def purge_page_from_cache(page):
|
||||
def purge_url_from_cache(url):
|
||||
# Get session
|
||||
cache_server_url = getattr(settings, 'WAGTAILFRONTENDCACHE_LOCATION', 'http://127.0.0.1:8000/')
|
||||
session = requests.Session()
|
||||
session.mount('http://', CustomHTTPAdapter(cache_server_url))
|
||||
|
||||
# Purge paths from cache
|
||||
for path in page.get_cached_paths():
|
||||
session.request('PURGE', page.full_url + path[1:])
|
||||
# Send purge request to cache
|
||||
session.request('PURGE', url)
|
||||
|
||||
|
||||
def purge_page_from_cache(page):
|
||||
# Purge cached paths from cache
|
||||
for path in page.specific.get_cached_paths():
|
||||
purge_url_from_cache(page.full_url + path[1:])
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,23 +38,27 @@ 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)
|
||||
urls = [url['location'] for url in sitemap.get_urls()]
|
||||
|
||||
self.assertIn('/', urls) # Homepage
|
||||
self.assertIn('/hello-world/', urls) # Child page
|
||||
self.assertIn('http://localhost/', urls) # Homepage
|
||||
self.assertIn('http://localhost/hello-world/', urls) # Child page
|
||||
|
||||
def test_render(self):
|
||||
sitemap = Sitemap(self.site)
|
||||
xml = sitemap.render()
|
||||
|
||||
# Check that a URL has made it into the xml
|
||||
self.assertIn('/hello-world/', xml)
|
||||
self.assertIn('http://localhost/hello-world/', xml)
|
||||
|
||||
# Make sure the unpublished page didn't make it into the xml
|
||||
self.assertNotIn('/unpublished/', xml)
|
||||
self.assertNotIn('http://localhost/unpublished/', xml)
|
||||
|
||||
# Make sure the protected page didn't make it into the xml
|
||||
self.assertNotIn('http://localhost/protected/', xml)
|
||||
|
||||
|
||||
class TestSitemapView(TestCase):
|
||||
|
|
|
|||
92
wagtail/tests/fixtures/test.json
vendored
92
wagtail/tests/fixtures/test.json
vendored
|
|
@ -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",
|
||||
|
|
@ -489,6 +562,14 @@
|
|||
"submit_time": "2014-01-01T12:00:00.000Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pk": 1,
|
||||
"model": "tests.advert",
|
||||
"fields": {
|
||||
"text": "test_advert",
|
||||
"url": "http://www.example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"pk": 1,
|
||||
"model": "wagtaildocs.Document",
|
||||
|
|
@ -506,5 +587,14 @@
|
|||
"width": 0,
|
||||
"height": 0
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"pk": 1,
|
||||
"model": "wagtailcore.pageviewrestriction",
|
||||
"fields": {
|
||||
"page": 11,
|
||||
"password": "swordfish"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
|
|||
from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel
|
||||
from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormField
|
||||
from wagtail.wagtailsnippets.models import register_snippet
|
||||
from wagtail.wagtailsearch import indexed
|
||||
|
||||
|
||||
EVENT_AUDIENCE_CHOICES = (
|
||||
|
|
@ -106,6 +107,19 @@ class SimplePage(Page):
|
|||
content = models.TextField()
|
||||
|
||||
|
||||
class PageWithOldStyleRouteMethod(Page):
|
||||
"""
|
||||
Prior to Wagtail 0.4, the route() method on Page returned an HttpResponse
|
||||
rather than a Page instance. As subclasses of Page may override route,
|
||||
we need to continue accepting this convention (albeit as a deprecated API).
|
||||
"""
|
||||
content = models.TextField()
|
||||
template = 'tests/simple_page.html'
|
||||
|
||||
def route(self, request, path_components):
|
||||
return self.serve(request)
|
||||
|
||||
|
||||
# Event page
|
||||
|
||||
class EventPageCarouselItem(Orderable, CarouselItem):
|
||||
|
|
@ -166,6 +180,8 @@ class EventPage(Page):
|
|||
indexed_fields = ('get_audience_display', 'location', 'body')
|
||||
search_name = "Event"
|
||||
|
||||
password_required_template = 'tests/event_page_password_required.html'
|
||||
|
||||
EventPage.content_panels = [
|
||||
FieldPanel('title', classname="full title"),
|
||||
FieldPanel('date_from'),
|
||||
|
|
@ -316,3 +332,60 @@ class BusinessSubIndex(Page):
|
|||
|
||||
class BusinessChild(Page):
|
||||
subpage_types = []
|
||||
|
||||
|
||||
class SearchTest(models.Model, indexed.Indexed):
|
||||
title = models.CharField(max_length=255)
|
||||
content = models.TextField()
|
||||
live = models.BooleanField(default=False)
|
||||
published_date = models.DateField(null=True)
|
||||
|
||||
search_fields = [
|
||||
indexed.SearchField('title', partial_match=True),
|
||||
indexed.SearchField('content'),
|
||||
indexed.SearchField('callable_indexed_field'),
|
||||
indexed.FilterField('title'),
|
||||
indexed.FilterField('live'),
|
||||
indexed.FilterField('published_date'),
|
||||
]
|
||||
|
||||
def callable_indexed_field(self):
|
||||
return "Callable"
|
||||
|
||||
|
||||
class SearchTestChild(SearchTest):
|
||||
subtitle = models.CharField(max_length=255, null=True, blank=True)
|
||||
extra_content = models.TextField()
|
||||
|
||||
search_fields = SearchTest.search_fields + [
|
||||
indexed.SearchField('subtitle', partial_match=True),
|
||||
indexed.SearchField('extra_content'),
|
||||
]
|
||||
|
||||
|
||||
class SearchTestOldConfig(models.Model, indexed.Indexed):
|
||||
"""
|
||||
This tests that the Indexed class can correctly handle models that
|
||||
use the old "indexed_fields" configuration format.
|
||||
"""
|
||||
indexed_fields = {
|
||||
# A search field with predictive search and boosting
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'analyzer': 'edgengram_analyzer',
|
||||
'boost': 100,
|
||||
},
|
||||
|
||||
# A filter field
|
||||
'live': {
|
||||
'type': 'boolean',
|
||||
'index': 'not_analyzed',
|
||||
},
|
||||
}
|
||||
|
||||
class SearchTestOldConfigList(models.Model, indexed.Indexed):
|
||||
"""
|
||||
This tests that the Indexed class can correctly handle models that
|
||||
use the old "indexed_fields" configuration format using a list.
|
||||
"""
|
||||
indexed_fields = ['title', 'content']
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
from contextlib import contextmanager
|
||||
import warnings
|
||||
|
||||
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 +25,17 @@ class WagtailTestUtils(object):
|
|||
self.client.login(username='test', password='password')
|
||||
|
||||
return user
|
||||
|
||||
def assertRegex(self, *args, **kwargs):
|
||||
six.assertRegex(self, *args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def ignore_deprecation_warnings():
|
||||
with warnings.catch_warnings(record=True) as warning_list: # catch all warnings
|
||||
yield
|
||||
|
||||
# rethrow all warnings that were not DeprecationWarnings
|
||||
for w in warning_list:
|
||||
if not issubclass(w.category, DeprecationWarning):
|
||||
warnings.showwarning(message=w.message, category=w.category, filename=w.filename, lineno=w.lineno, file=w.file, line=w.line)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -83,3 +83,20 @@ class CopyForm(forms.Form):
|
|||
new_slug = forms.CharField()
|
||||
copy_subpages = forms.BooleanField(required=False)
|
||||
publish_copies = forms.BooleanField(required=False)
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -4,31 +4,28 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.db.models import Count
|
||||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
|
||||
from wagtail.wagtailsearch import Indexed, get_search_backend
|
||||
from wagtail.wagtailsearch import indexed
|
||||
from wagtail.wagtailsearch.backends import get_search_backend
|
||||
|
||||
|
||||
class TagSearchable(Indexed):
|
||||
class TagSearchable(indexed.Indexed):
|
||||
"""
|
||||
Mixin to provide a 'search' method, searching on the 'title' field and tags,
|
||||
for models that provide those things.
|
||||
"""
|
||||
|
||||
indexed_fields = {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'analyzer': 'edgengram_analyzer',
|
||||
'boost': 10,
|
||||
},
|
||||
'get_tags': {
|
||||
'type': 'string',
|
||||
'analyzer': 'edgengram_analyzer',
|
||||
'boost': 10,
|
||||
},
|
||||
}
|
||||
search_fields = (
|
||||
indexed.SearchField('title', partial_match=True, boost=10),
|
||||
indexed.SearchField('get_tags', partial_match=True, boost=10)
|
||||
)
|
||||
|
||||
@property
|
||||
def get_tags(self):
|
||||
return ' '.join([tag.name for tag in self.tags.all()])
|
||||
return ' '.join([tag.name for tag in self.prefetched_tags()])
|
||||
|
||||
@classmethod
|
||||
def get_indexed_objects(cls):
|
||||
return super(TagSearchable, cls).get_indexed_objects().prefetch_related('tagged_items__tag')
|
||||
|
||||
@classmethod
|
||||
def search(cls, q, results_per_page=None, page=1, prefetch_tags=False, filters={}):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
function(modal) {
|
||||
modal.respond('setPermission', {% if is_public %}true{% else %}false{% endif %});
|
||||
modal.close();
|
||||
}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -39,12 +39,12 @@
|
|||
|
||||
<li class="actions preview">
|
||||
{% trans 'Preview' as preview_label %}
|
||||
{% if display_modes|length > 1 %}
|
||||
{% if preview_modes|length > 1 %}
|
||||
<div class="dropdown dropup dropdown-button match-width">
|
||||
{% include "wagtailadmin/pages/_preview_button_on_create.html" with label=preview_label icon=1 %}
|
||||
<div class="dropdown-toggle icon icon-arrow-up"></div>
|
||||
<ul role="menu">
|
||||
{% for mode_name, mode_display_name in display_modes %}
|
||||
{% for mode_name, mode_display_name in preview_modes %}
|
||||
<li>
|
||||
{% include "wagtailadmin/pages/_preview_button_on_create.html" with mode=mode_name label=mode_display_name %}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -47,12 +54,12 @@
|
|||
|
||||
<li class="actions preview">
|
||||
{% trans 'Preview' as preview_label %}
|
||||
{% if display_modes|length > 1 %}
|
||||
{% if preview_modes|length > 1 %}
|
||||
<div class="dropdown dropup dropdown-button match-width">
|
||||
{% include "wagtailadmin/pages/_preview_button_on_edit.html" with label=preview_label icon=1 %}
|
||||
<div class="dropdown-toggle icon icon-arrow-up"></div>
|
||||
<ul role="menu">
|
||||
{% for mode_name, mode_display_name in display_modes %}
|
||||
{% for mode_name, mode_display_name in preview_modes %}
|
||||
<li>
|
||||
{% include "wagtailadmin/pages/_preview_button_on_edit.html" with mode=mode_name label=mode_display_name %}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -252,4 +276,4 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
403
wagtail/wagtailadmin/tests/test_edit_handlers.py
Normal file
403
wagtail/wagtailadmin/tests/test_edit_handlers.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
from mock import MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.forms.widgets import HiddenInput
|
||||
|
||||
from wagtail.wagtailadmin.edit_handlers import (
|
||||
get_form_for_model,
|
||||
extract_panel_definitions_from_model_class,
|
||||
BaseFieldPanel,
|
||||
FieldPanel,
|
||||
RichTextFieldPanel,
|
||||
EditHandler,
|
||||
WagtailAdminModelForm,
|
||||
BaseTabbedInterface,
|
||||
TabbedInterface,
|
||||
BaseObjectList,
|
||||
ObjectList,
|
||||
PageChooserPanel,
|
||||
InlinePanel
|
||||
)
|
||||
from wagtail.wagtailcore.models import Page, Site
|
||||
|
||||
|
||||
class TestGetFormForModel(TestCase):
|
||||
class FakeClass(object):
|
||||
_meta = MagicMock()
|
||||
|
||||
def setUp(self):
|
||||
self.mock_exclude = MagicMock()
|
||||
|
||||
def test_get_form_for_model(self):
|
||||
form = get_form_for_model(self.FakeClass,
|
||||
fields=[],
|
||||
exclude=[self.mock_exclude],
|
||||
formsets=['baz'],
|
||||
exclude_formsets=['quux'],
|
||||
widgets=['bacon'])
|
||||
self.assertEqual(form.Meta.exclude, [self.mock_exclude])
|
||||
self.assertEqual(form.Meta.formsets, ['baz'])
|
||||
self.assertEqual(form.Meta.exclude_formsets, ['quux'])
|
||||
self.assertEqual(form.Meta.widgets, ['bacon'])
|
||||
|
||||
|
||||
class TestExtractPanelDefinitionsFromModelClass(TestCase):
|
||||
class FakePage(Page):
|
||||
pass
|
||||
|
||||
def test_can_extract_panels(self):
|
||||
mock = MagicMock()
|
||||
mock.panels = 'foo'
|
||||
result = extract_panel_definitions_from_model_class(mock)
|
||||
self.assertEqual(result, 'foo')
|
||||
|
||||
def test_exclude(self):
|
||||
panels = extract_panel_definitions_from_model_class(Site, exclude=['hostname'])
|
||||
for panel in panels:
|
||||
self.assertNotEqual(panel.field_name, 'hostname')
|
||||
|
||||
def test_extracted_objects_are_panels(self):
|
||||
panels = extract_panel_definitions_from_model_class(self.FakePage)
|
||||
for panel in panels:
|
||||
self.assertTrue(issubclass(panel, BaseFieldPanel))
|
||||
|
||||
|
||||
class TestTabbedInterface(TestCase):
|
||||
class FakeChild(object):
|
||||
class FakeGrandchild(object):
|
||||
def render_js(self):
|
||||
return "rendered js"
|
||||
|
||||
def rendered_fields(self):
|
||||
return ["rendered fields"]
|
||||
|
||||
def widget_overrides(self):
|
||||
return {'foo': 'bar'}
|
||||
|
||||
def required_formsets(self):
|
||||
return {'baz': 'quux'}
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
fake_grandchild = self.FakeGrandchild()
|
||||
return fake_grandchild
|
||||
|
||||
def setUp(self):
|
||||
fake_child = self.FakeChild()
|
||||
self.TabbedInterfaceClass = TabbedInterface([fake_child])
|
||||
self.tabbed_interface = self.TabbedInterfaceClass(instance=True,
|
||||
form=True)
|
||||
|
||||
def test_tabbed_interface(self):
|
||||
self.assertTrue(issubclass(self.TabbedInterfaceClass,
|
||||
BaseTabbedInterface))
|
||||
|
||||
def test_widget_overrides(self):
|
||||
result = self.tabbed_interface.widget_overrides()
|
||||
self.assertEqual(result, {'foo': 'bar'})
|
||||
|
||||
def test_required_formsets(self):
|
||||
result = self.tabbed_interface.required_formsets()
|
||||
self.assertEqual(result, ['baz'])
|
||||
|
||||
def test_render(self):
|
||||
result = self.tabbed_interface.render()
|
||||
self.assertIn('<div class="tab-content">', result)
|
||||
|
||||
def test_render_js(self):
|
||||
result = self.tabbed_interface.render_js()
|
||||
self.assertEqual(result, 'rendered js')
|
||||
|
||||
def test_rendered_fields(self):
|
||||
result = self.tabbed_interface.rendered_fields()
|
||||
self.assertEqual(result, ["rendered fields"])
|
||||
|
||||
|
||||
class TestObjectList(TestCase):
|
||||
def test_object_list(self):
|
||||
object_list = ObjectList(['foo'])
|
||||
self.assertTrue(issubclass(object_list, BaseObjectList))
|
||||
|
||||
|
||||
class TestFieldPanel(TestCase):
|
||||
class FakeClass(object):
|
||||
required = False
|
||||
widget = 'fake widget'
|
||||
|
||||
class FakeField(object):
|
||||
label = 'label'
|
||||
help_text = 'help text'
|
||||
errors = ['errors']
|
||||
id_for_label = 'id for label'
|
||||
|
||||
class FakeForm(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.fields = self.fields_iterator()
|
||||
|
||||
def fields_iterator(self):
|
||||
for i in self:
|
||||
yield i
|
||||
|
||||
def setUp(self):
|
||||
fake_field = self.FakeField()
|
||||
fake_field.field = self.FakeClass()
|
||||
self.field_panel = FieldPanel('barbecue', 'snowman')(
|
||||
instance=True,
|
||||
form={'barbecue': fake_field})
|
||||
|
||||
def test_render_as_object(self):
|
||||
result = self.field_panel.render_as_object()
|
||||
self.assertIn('<legend>label</legend>',
|
||||
result)
|
||||
self.assertIn('<p class="error-message">',
|
||||
result)
|
||||
|
||||
def test_render_js_unknown_widget(self):
|
||||
field = self.FakeField()
|
||||
bound_field = self.FakeField()
|
||||
widget = self.FakeField()
|
||||
field.widget = widget
|
||||
bound_field.field = field
|
||||
self.field_panel.bound_field = bound_field
|
||||
result = self.field_panel.render_js()
|
||||
self.assertEqual(result,
|
||||
'')
|
||||
|
||||
def test_render_as_field(self):
|
||||
field = self.FakeField()
|
||||
bound_field = self.FakeField()
|
||||
bound_field.field = field
|
||||
self.field_panel.bound_field = bound_field
|
||||
result = self.field_panel.render_as_field()
|
||||
self.assertIn('<p class="help">help text</p>',
|
||||
result)
|
||||
self.assertIn('<span>errors</span>',
|
||||
result)
|
||||
|
||||
def test_rendered_fields(self):
|
||||
result = self.field_panel.rendered_fields()
|
||||
self.assertEqual(result, ['barbecue'])
|
||||
|
||||
def test_field_type(self):
|
||||
fake_object = self.FakeClass()
|
||||
another_fake_object = self.FakeClass()
|
||||
fake_object.field = another_fake_object
|
||||
self.field_panel.bound_field = fake_object
|
||||
self.assertEqual(self.field_panel.field_type(), 'fake_class')
|
||||
|
||||
def test_widget_overrides(self):
|
||||
result = FieldPanel('barbecue', 'snowman').widget_overrides()
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_required_formsets(self):
|
||||
result = FieldPanel('barbecue', 'snowman').required_formsets()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_get_form_class(self):
|
||||
result = FieldPanel('barbecue', 'snowman').get_form_class(Page)
|
||||
self.assertTrue(issubclass(result, WagtailAdminModelForm))
|
||||
|
||||
def test_render_js(self):
|
||||
result = self.field_panel.render_js()
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_render_missing_fields(self):
|
||||
fake_form = self.FakeForm()
|
||||
fake_form["foo"] = "bar"
|
||||
self.field_panel.form = fake_form
|
||||
self.assertEqual(self.field_panel.render_missing_fields(), "bar")
|
||||
|
||||
def test_render_form_content(self):
|
||||
fake_form = self.FakeForm()
|
||||
fake_form["foo"] = "bar"
|
||||
self.field_panel.form = fake_form
|
||||
self.assertIn("bar", self.field_panel.render_form_content())
|
||||
|
||||
|
||||
class TestRichTextFieldPanel(TestCase):
|
||||
class FakeField(object):
|
||||
label = 'label'
|
||||
help_text = 'help text'
|
||||
errors = ['errors']
|
||||
id_for_label = 'id for label'
|
||||
|
||||
def test_render_js(self):
|
||||
fake_field = self.FakeField()
|
||||
rich_text_field_panel = RichTextFieldPanel('barbecue')(
|
||||
instance=True,
|
||||
form={'barbecue': fake_field})
|
||||
result = rich_text_field_panel.render_js()
|
||||
self.assertEqual(result,
|
||||
"makeRichTextEditable(fixPrefix('id for label'));")
|
||||
|
||||
|
||||
class TestPageChooserPanel(TestCase):
|
||||
class FakeField(object):
|
||||
label = 'label'
|
||||
help_text = 'help text'
|
||||
errors = ['errors']
|
||||
id_for_label = 'id for label'
|
||||
|
||||
class FakeInstance(object):
|
||||
class FakePage(object):
|
||||
class FakeParent(object):
|
||||
id = 1
|
||||
|
||||
name = 'fake page'
|
||||
|
||||
def get_parent(self):
|
||||
return self.FakeParent()
|
||||
|
||||
def __init__(self):
|
||||
fake_page = self.FakePage()
|
||||
self.barbecue = fake_page
|
||||
|
||||
def setUp(self):
|
||||
fake_field = self.FakeField()
|
||||
fake_instance = self.FakeInstance()
|
||||
self.page_chooser_panel = PageChooserPanel('barbecue')(
|
||||
instance=fake_instance,
|
||||
form={'barbecue': fake_field})
|
||||
|
||||
def test_render_js(self):
|
||||
result = self.page_chooser_panel.render_js()
|
||||
self.assertEqual(result,
|
||||
"createPageChooser(fixPrefix('id for label'), 'wagtailcore.page', 1);")
|
||||
|
||||
def test_get_chosen_item(self):
|
||||
result = self.page_chooser_panel.get_chosen_item()
|
||||
self.assertEqual(result.name, 'fake page')
|
||||
|
||||
def test_render_as_field(self):
|
||||
result = self.page_chooser_panel.render_as_field()
|
||||
self.assertIn('<p class="help">help text</p>', result)
|
||||
self.assertIn('<span>errors</span>', result)
|
||||
|
||||
def test_widget_overrides(self):
|
||||
result = self.page_chooser_panel.widget_overrides()
|
||||
self.assertEqual(result, {'barbecue': HiddenInput})
|
||||
|
||||
def test_target_content_type(self):
|
||||
result = PageChooserPanel(
|
||||
'barbecue',
|
||||
'wagtailcore.site'
|
||||
).target_content_type()
|
||||
self.assertEqual(result.name, 'site')
|
||||
|
||||
def test_target_content_type_malformed_type(self):
|
||||
result = PageChooserPanel(
|
||||
'barbecue',
|
||||
'snowman'
|
||||
)
|
||||
self.assertRaises(ImproperlyConfigured,
|
||||
result.target_content_type)
|
||||
|
||||
def test_target_content_type_nonexistent_type(self):
|
||||
result = PageChooserPanel(
|
||||
'barbecue',
|
||||
'snowman.lorry'
|
||||
)
|
||||
self.assertRaises(ImproperlyConfigured,
|
||||
result.target_content_type)
|
||||
|
||||
|
||||
class TestInlinePanel(TestCase):
|
||||
class FakeField(object):
|
||||
class FakeFormset(object):
|
||||
class FakeForm(object):
|
||||
class FakeInstance(object):
|
||||
def __repr__(self):
|
||||
return 'fake instance'
|
||||
fields = {'DELETE': MagicMock(),
|
||||
'ORDER': MagicMock()}
|
||||
instance = FakeInstance()
|
||||
|
||||
cleaned_data = {
|
||||
'ORDER': 0,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return 'fake form'
|
||||
|
||||
forms = [FakeForm()]
|
||||
empty_form = FakeForm()
|
||||
can_order = True
|
||||
|
||||
def is_valid(self):
|
||||
return True
|
||||
|
||||
label = 'label'
|
||||
help_text = 'help text'
|
||||
errors = ['errors']
|
||||
id_for_label = 'id for label'
|
||||
formsets = {'formset': FakeFormset()}
|
||||
|
||||
class FakeInstance(object):
|
||||
class FakePage(object):
|
||||
class FakeParent(object):
|
||||
id = 1
|
||||
|
||||
name = 'fake page'
|
||||
|
||||
def get_parent(self):
|
||||
return self.FakeParent()
|
||||
|
||||
def __init__(self):
|
||||
fake_page = self.FakePage()
|
||||
self.barbecue = fake_page
|
||||
|
||||
def setUp(self):
|
||||
self.fake_field = self.FakeField()
|
||||
self.fake_instance = self.FakeInstance()
|
||||
self.mock_panel = MagicMock()
|
||||
self.mock_panel.name = 'mock panel'
|
||||
self.mock_model = MagicMock()
|
||||
self.mock_model.formset.related.model.panels = [self.mock_panel]
|
||||
|
||||
def test_get_panel_definitions_no_panels(self):
|
||||
"""
|
||||
Check that get_panel_definitions returns the panels set on the model
|
||||
when no panels are set on the InlinePanel
|
||||
"""
|
||||
inline_panel = InlinePanel(self.mock_model, 'formset')(
|
||||
instance=self.fake_instance,
|
||||
form=self.fake_field)
|
||||
result = inline_panel.get_panel_definitions()
|
||||
self.assertEqual(result[0].name, 'mock panel')
|
||||
|
||||
def test_get_panel_definitions(self):
|
||||
"""
|
||||
Check that get_panel_definitions returns the panels set on
|
||||
InlinePanel
|
||||
"""
|
||||
other_mock_panel = MagicMock()
|
||||
other_mock_panel.name = 'other mock panel'
|
||||
inline_panel = InlinePanel(self.mock_model,
|
||||
'formset',
|
||||
panels=[other_mock_panel])(
|
||||
instance=self.fake_instance,
|
||||
form=self.fake_field)
|
||||
result = inline_panel.get_panel_definitions()
|
||||
self.assertEqual(result[0].name, 'other mock panel')
|
||||
|
||||
def test_required_formsets(self):
|
||||
inline_panel = InlinePanel(self.mock_model, 'formset')(
|
||||
instance=self.fake_instance,
|
||||
form=self.fake_field)
|
||||
self.assertEqual(inline_panel.required_formsets(), ['formset'])
|
||||
|
||||
def test_render(self):
|
||||
inline_panel = InlinePanel(self.mock_model,
|
||||
'formset',
|
||||
label='foo')(
|
||||
instance=self.fake_instance,
|
||||
form=self.fake_field)
|
||||
self.assertIn('Add foo', inline_panel.render())
|
||||
|
||||
def test_render_js(self):
|
||||
inline_panel = InlinePanel(self.mock_model,
|
||||
'formset')(
|
||||
instance=self.fake_instance,
|
||||
form=self.fake_field)
|
||||
self.assertIn('var panel = InlinePanel({',
|
||||
inline_panel.render_js())
|
||||
|
|
@ -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'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
|
||||
|
|
|
|||
261
wagtail/wagtailadmin/tests/test_privacy.py
Normal file
261
wagtail/wagtailadmin/tests/test_privacy.py
Normal 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">')
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -69,6 +69,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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
wagtail/wagtailadmin/views/page_privacy.py
Normal file
77
wagtail/wagtailadmin/views/page_privacy.py
Normal 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,
|
||||
}
|
||||
)
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import warnings
|
||||
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.core.exceptions import ValidationError, PermissionDenied
|
||||
|
|
@ -7,6 +9,7 @@ from django.contrib.auth.decorators import permission_required
|
|||
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.decorators.vary import vary_on_headers
|
||||
|
||||
from wagtail.wagtailadmin.edit_handlers import TabbedInterface, ObjectList
|
||||
|
|
@ -230,7 +233,7 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_
|
|||
'page_class': page_class,
|
||||
'parent_page': parent_page,
|
||||
'edit_handler': edit_handler,
|
||||
'display_modes': page.get_page_modes(),
|
||||
'preview_modes': page.preview_modes,
|
||||
'form': form, # Used in unit tests
|
||||
})
|
||||
|
||||
|
|
@ -361,7 +364,7 @@ def edit(request, page_id):
|
|||
'page': page,
|
||||
'edit_handler': edit_handler,
|
||||
'errors_debug': errors_debug,
|
||||
'display_modes': page.get_page_modes(),
|
||||
'preview_modes': page.preview_modes,
|
||||
'form': form, # Used in unit tests
|
||||
})
|
||||
|
||||
|
|
@ -393,7 +396,28 @@ def delete(request, page_id):
|
|||
@permission_required('wagtailadmin.access_admin')
|
||||
def view_draft(request, page_id):
|
||||
page = get_object_or_404(Page, id=page_id).get_latest_revision_as_page()
|
||||
return page.serve(request)
|
||||
return page.serve_preview(page.dummy_request(), page.default_preview_mode)
|
||||
|
||||
|
||||
def get_preview_response(page, preview_mode):
|
||||
"""
|
||||
Helper function for preview_on_edit and preview_on_create -
|
||||
return a page's preview response via either serve_preview or the deprecated
|
||||
show_as_mode method
|
||||
"""
|
||||
# Check the deprecated Page.show_as_mode method, as subclasses of Page
|
||||
# might be overriding that to return a response
|
||||
response = page.show_as_mode(preview_mode)
|
||||
if response:
|
||||
warnings.warn(
|
||||
"Defining 'show_as_mode' on a page model is deprecated. Use 'serve_preview' instead",
|
||||
DeprecationWarning
|
||||
)
|
||||
return response
|
||||
else:
|
||||
# show_as_mode did not return a response, so go ahead and use the 'proper'
|
||||
# serve_preview method
|
||||
return page.serve_preview(page.dummy_request(), preview_mode)
|
||||
|
||||
|
||||
@permission_required('wagtailadmin.access_admin')
|
||||
|
|
@ -409,19 +433,8 @@ def preview_on_edit(request, page_id):
|
|||
if form.is_valid():
|
||||
form.save(commit=False)
|
||||
|
||||
# This view will generally be invoked as an AJAX request; as such, in the case of
|
||||
# an error Django will return a plaintext response. This isn't what we want, since
|
||||
# we will be writing the response back to an HTML page regardless of success or
|
||||
# failure - as such, we strip out the X-Requested-With header to get Django to return
|
||||
# an HTML error response
|
||||
request.META.pop('HTTP_X_REQUESTED_WITH', None)
|
||||
|
||||
try:
|
||||
display_mode = request.GET['mode']
|
||||
except KeyError:
|
||||
display_mode = page.get_page_modes()[0][0]
|
||||
|
||||
response = page.show_as_mode(display_mode)
|
||||
preview_mode = request.GET.get('mode', page.default_preview_mode)
|
||||
response = get_preview_response(page, preview_mode)
|
||||
|
||||
response['X-Wagtail-Preview'] = 'ok'
|
||||
return response
|
||||
|
|
@ -432,7 +445,7 @@ def preview_on_edit(request, page_id):
|
|||
response = render(request, 'wagtailadmin/pages/edit.html', {
|
||||
'page': page,
|
||||
'edit_handler': edit_handler,
|
||||
'display_modes': page.get_page_modes(),
|
||||
'preview_modes': page.preview_modes,
|
||||
})
|
||||
response['X-Wagtail-Preview'] = 'error'
|
||||
return response
|
||||
|
|
@ -465,18 +478,8 @@ def preview_on_create(request, content_type_app_name, content_type_model_name, p
|
|||
page.depth = parent_page.depth + 1
|
||||
page.path = Page._get_children_path_interval(parent_page.path)[1]
|
||||
|
||||
# This view will generally be invoked as an AJAX request; as such, in the case of
|
||||
# an error Django will return a plaintext response. This isn't what we want, since
|
||||
# we will be writing the response back to an HTML page regardless of success or
|
||||
# failure - as such, we strip out the X-Requested-With header to get Django to return
|
||||
# an HTML error response
|
||||
request.META.pop('HTTP_X_REQUESTED_WITH', None)
|
||||
|
||||
try:
|
||||
display_mode = request.GET['mode']
|
||||
except KeyError:
|
||||
display_mode = page.get_page_modes()[0][0]
|
||||
response = page.show_as_mode(display_mode)
|
||||
preview_mode = request.GET.get('mode', page.default_preview_mode)
|
||||
response = get_preview_response(page, preview_mode)
|
||||
|
||||
response['X-Wagtail-Preview'] = 'ok'
|
||||
return response
|
||||
|
|
@ -490,7 +493,7 @@ def preview_on_create(request, content_type_app_name, content_type_model_name, p
|
|||
'page_class': page_class,
|
||||
'parent_page': parent_page,
|
||||
'edit_handler': edit_handler,
|
||||
'display_modes': page.get_page_modes(),
|
||||
'preview_modes': page.preview_modes,
|
||||
})
|
||||
response['X-Wagtail-Preview'] = 'error'
|
||||
return response
|
||||
|
|
@ -794,6 +797,7 @@ def reject_moderation(request, revision_id):
|
|||
|
||||
|
||||
@permission_required('wagtailadmin.access_admin')
|
||||
@require_GET
|
||||
def preview_for_moderation(request, revision_id):
|
||||
revision = get_object_or_404(PageRevision, id=revision_id)
|
||||
if not revision.page.permissions_for_user(request.user).can_publish():
|
||||
|
|
@ -807,4 +811,6 @@ def preview_for_moderation(request, revision_id):
|
|||
|
||||
request.revision_id = revision_id
|
||||
|
||||
return page.serve(request)
|
||||
# pass in the real user request rather than page.dummy_request(), so that request.user
|
||||
# and request.revision_id will be picked up by the wagtail user bar
|
||||
return page.serve_preview(request, page.default_preview_mode)
|
||||
|
|
|
|||
16
wagtail/wagtailcore/forms.py
Normal file
16
wagtail/wagtailcore/forms.py
Normal 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
|
||||
|
|
@ -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']
|
||||
|
|
@ -27,8 +27,10 @@ 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, get_search_backend
|
||||
from wagtail.wagtailsearch import indexed
|
||||
from wagtail.wagtailsearch.backends import get_search_backend
|
||||
|
||||
|
||||
class SiteManager(models.Manager):
|
||||
|
|
@ -227,6 +229,15 @@ 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()
|
||||
|
||||
def search(self, query_string, fields=None, backend='default'):
|
||||
return self.get_queryset().search(query_string, fields=fields, backend=backend)
|
||||
|
||||
|
||||
class PageBase(models.base.ModelBase):
|
||||
"""Metaclass for Page"""
|
||||
|
|
@ -260,7 +271,7 @@ class PageBase(models.base.ModelBase):
|
|||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
||||
class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, indexed.Indexed)):
|
||||
title = models.CharField(max_length=255, help_text=_("The page title as you'd like it to be seen by the public"))
|
||||
slug = models.SlugField(help_text=_("The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/"))
|
||||
# TODO: enforce uniqueness on slug field per parent (will have to be done at the Django
|
||||
|
|
@ -279,21 +290,15 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
expire_at = models.DateTimeField(verbose_name=_("Expiry date/time"), help_text=_("Please add a date-time in the form YYYY-MM-DD hh:mm."), blank=True, null=True)
|
||||
expired = models.BooleanField(default=False, editable=False)
|
||||
|
||||
indexed_fields = {
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'analyzer': 'edgengram_analyzer',
|
||||
'boost': 100,
|
||||
},
|
||||
'live': {
|
||||
'type': 'boolean',
|
||||
'index': 'not_analyzed',
|
||||
},
|
||||
'path': {
|
||||
'type': 'string',
|
||||
'index': 'not_analyzed',
|
||||
},
|
||||
}
|
||||
search_fields = (
|
||||
indexed.SearchField('title', partial_match=True, boost=100),
|
||||
indexed.FilterField('id'),
|
||||
indexed.FilterField('live'),
|
||||
indexed.FilterField('owner'),
|
||||
indexed.FilterField('content_type'),
|
||||
indexed.FilterField('path'),
|
||||
indexed.FilterField('depth'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Page, self).__init__(*args, **kwargs)
|
||||
|
|
@ -415,7 +420,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
else:
|
||||
# request is for this very page
|
||||
if self.live:
|
||||
return self.serve(request)
|
||||
return RouteResult(self)
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
|
|
@ -523,7 +528,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
|
||||
# Search
|
||||
s = get_search_backend()
|
||||
return s.search(query_string, model=cls, fields=fields, filters=filters, prefetch_related=prefetch_related)
|
||||
return s.search(query_string, cls, fields=fields, filters=filters, prefetch_related=prefetch_related)
|
||||
|
||||
@classmethod
|
||||
def clean_subpage_types(cls):
|
||||
|
|
@ -706,25 +711,64 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
"request middleware returned a response")
|
||||
return request
|
||||
|
||||
def get_page_modes(self):
|
||||
DEFAULT_PREVIEW_MODES = [('', 'Default')]
|
||||
|
||||
@property
|
||||
def preview_modes(self):
|
||||
"""
|
||||
Return a list of (internal_name, display_name) tuples for the modes in which
|
||||
A list of (internal_name, display_name) tuples for the modes in which
|
||||
this page can be displayed for preview/moderation purposes. Ordinarily a page
|
||||
will only have one display mode, but subclasses of Page can override this -
|
||||
for example, a page containing a form might have a default view of the form,
|
||||
and a post-submission 'thankyou' page
|
||||
"""
|
||||
return [('', 'Default')]
|
||||
modes = self.get_page_modes()
|
||||
if modes is not Page.DEFAULT_PREVIEW_MODES:
|
||||
# User has overriden get_page_modes instead of using preview_modes
|
||||
warnings.warn("Overriding get_page_modes is deprecated. Define a preview_modes property instead", DeprecationWarning)
|
||||
|
||||
return modes
|
||||
|
||||
def get_page_modes(self):
|
||||
# Deprecated accessor for the preview_modes property
|
||||
return Page.DEFAULT_PREVIEW_MODES
|
||||
|
||||
@property
|
||||
def default_preview_mode(self):
|
||||
return self.preview_modes[0][0]
|
||||
|
||||
def serve_preview(self, request, mode_name):
|
||||
"""
|
||||
Return an HTTP response for use in page previews. Normally this would be equivalent
|
||||
to self.serve(request), since we obviously want the preview to be indicative of how
|
||||
it looks on the live site. However, there are a couple of cases where this is not
|
||||
appropriate, and custom behaviour is required:
|
||||
|
||||
1) The page has custom routing logic that derives some additional required
|
||||
args/kwargs to be passed to serve(). The routing mechanism is bypassed when
|
||||
previewing, so there's no way to know what args we should pass. In such a case,
|
||||
the page model needs to implement its own version of serve_preview.
|
||||
|
||||
2) The page has several different renderings that we would like to be able to see
|
||||
when previewing - for example, a form page might have one rendering that displays
|
||||
the form, and another rendering to display a landing page when the form is posted.
|
||||
This can be done by setting a custom preview_modes list on the page model -
|
||||
Wagtail will allow the user to specify one of those modes when previewing, and
|
||||
pass the chosen mode_name to serve_preview so that the page model can decide how
|
||||
to render it appropriately. (Page models that do not specify their own preview_modes
|
||||
list will always receive an empty string as mode_name.)
|
||||
|
||||
Any templates rendered during this process should use the 'request' object passed
|
||||
here - this ensures that request.user and other properties are set appropriately for
|
||||
the wagtail user bar to be displayed. This request will always be a GET.
|
||||
"""
|
||||
return self.serve(request)
|
||||
|
||||
def show_as_mode(self, mode_name):
|
||||
"""
|
||||
Given an internal name from the get_page_modes() list, return an HTTP response
|
||||
indicative of the page being viewed in that mode. By default this passes a
|
||||
dummy request into the serve() mechanism, ensuring that it matches the behaviour
|
||||
on the front-end; subclasses that define additional page modes will need to
|
||||
implement alternative logic to serve up the appropriate view here.
|
||||
"""
|
||||
return self.serve(self.dummy_request())
|
||||
# Deprecated API for rendering previews. If this returns something other than None,
|
||||
# we know that a subclass of Page has overridden this, and we should try to work with
|
||||
# that response if possible.
|
||||
return None
|
||||
|
||||
def get_cached_paths(self):
|
||||
"""
|
||||
|
|
@ -737,7 +781,7 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
|
||||
return [
|
||||
{
|
||||
'location': self.url,
|
||||
'location': self.full_url,
|
||||
'lastmod': latest_revision.created_at if latest_revision else None
|
||||
}
|
||||
]
|
||||
|
|
@ -770,6 +814,24 @@ class Page(six.with_metaclass(PageBase, MP_Node, ClusterableModel, Indexed)):
|
|||
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,
|
||||
|
|
@ -960,10 +1022,9 @@ class UserPagePermissionsProxy(object):
|
|||
|
||||
return editable_pages
|
||||
|
||||
|
||||
def can_edit_pages(self):
|
||||
"""Return True if the user has permission to edit any pages"""
|
||||
return True if self.editable_pages().count() else False
|
||||
return self.editable_pages().exists()
|
||||
|
||||
def publishable_pages(self):
|
||||
"""Return a queryset of the pages that this user has permission to publish"""
|
||||
|
|
@ -973,27 +1034,18 @@ class UserPagePermissionsProxy(object):
|
|||
if self.user.is_superuser:
|
||||
return Page.objects.all()
|
||||
|
||||
# Translate each of the user's permission rules into a Q-expression
|
||||
q_expressions = []
|
||||
for perm in self.permissions:
|
||||
if perm.permission_type == 'publish':
|
||||
# user has publish permission on any subpage of perm.page
|
||||
# (including perm.page itself)
|
||||
q_expressions.append(
|
||||
Q(path__startswith=perm.page.path)
|
||||
)
|
||||
publishable_pages = Page.objects.none()
|
||||
|
||||
if q_expressions:
|
||||
all_rules = q_expressions[0]
|
||||
for expr in q_expressions[1:]:
|
||||
all_rules = all_rules | expr
|
||||
return Page.objects.filter(all_rules)
|
||||
else:
|
||||
return Page.objects.none()
|
||||
for perm in self.permissions.filter(permission_type='publish'):
|
||||
# user has publish permission on any subpage of perm.page
|
||||
# (including perm.page itself)
|
||||
publishable_pages |= Page.objects.descendant_of(perm.page, inclusive=True)
|
||||
|
||||
return publishable_pages
|
||||
|
||||
def can_publish_pages(self):
|
||||
"""Return True if the user has permission to publish any pages"""
|
||||
return True if self.publishable_pages().count() else False
|
||||
return self.publishable_pages().exists()
|
||||
|
||||
|
||||
class PagePermissionTester(object):
|
||||
|
|
@ -1064,6 +1116,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.
|
||||
|
|
@ -1121,3 +1176,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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from django.db.models import Q
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from treebeard.mp_tree import MP_NodeQuerySet
|
||||
|
||||
from wagtail.wagtailsearch.backends import get_search_backend
|
||||
|
||||
|
||||
class PageQuerySet(MP_NodeQuerySet):
|
||||
"""
|
||||
|
|
@ -107,3 +109,21 @@ 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())
|
||||
|
||||
def search(self, query_string, fields=None, backend='default'):
|
||||
search_backend = get_search_backend(backend)
|
||||
return search_backend.search(query_string, self, fields=None)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
70
wagtail/wagtailcore/tests/test_page_privacy.py
Normal file
70
wagtail/wagtailcore/tests/test_page_privacy.py
Normal 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')
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
15
wagtail/wagtailcore/url_routing.py
Normal file
15
wagtail/wagtailcore/url_routing.py
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
25
wagtail/wagtailcore/wagtail_hooks.py
Normal file
25
wagtail/wagtailcore/wagtail_hooks.py
Normal 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)
|
||||
|
|
@ -12,6 +12,7 @@ from django.utils.translation import ugettext_lazy as _
|
|||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from wagtail.wagtailadmin.taggable import TagSearchable
|
||||
from wagtail.wagtailsearch import indexed
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
|
|
@ -23,14 +24,9 @@ class Document(models.Model, TagSearchable):
|
|||
|
||||
tags = TaggableManager(help_text=None, blank=True, verbose_name=_('Tags'))
|
||||
|
||||
indexed_fields = {
|
||||
'uploaded_by_user_id': {
|
||||
'type': 'integer',
|
||||
'store': 'yes',
|
||||
'indexed': 'no',
|
||||
'boost': 0,
|
||||
},
|
||||
}
|
||||
search_fields = TagSearchable.search_fields + (
|
||||
indexed.FilterField('uploaded_by_user'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
|
|
|||
|
|
@ -301,49 +301,3 @@ class TestEmbedFilter(TestCase):
|
|||
context = template.Context()
|
||||
result = temp.render(context)
|
||||
self.assertEqual(result, '')
|
||||
|
||||
|
||||
class TestEmbedlyFilter(TestEmbedFilter):
|
||||
def setUp(self):
|
||||
class DummyResponse(object):
|
||||
def read(self):
|
||||
return "foo"
|
||||
self.dummy_response = DummyResponse()
|
||||
|
||||
@patch('six.moves.urllib.request.urlopen')
|
||||
@patch('json.loads')
|
||||
def test_valid_embed(self, loads, urlopen):
|
||||
urlopen.return_value = self.dummy_response
|
||||
loads.return_value = {'type': 'photo',
|
||||
'url': 'http://www.example.com'}
|
||||
result = embedly_filter('http://www.youtube.com/watch/')
|
||||
self.assertEqual(result, '<img src="http://www.example.com" />')
|
||||
|
||||
@patch('six.moves.urllib.request.urlopen')
|
||||
@patch('json.loads')
|
||||
def test_render_filter(self, loads, urlopen):
|
||||
urlopen.return_value = self.dummy_response
|
||||
loads.return_value = {'type': 'photo',
|
||||
'url': 'http://www.example.com'}
|
||||
temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embedly }}')
|
||||
context = template.Context()
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
result = temp.render(context)
|
||||
|
||||
# Check that a DeprecationWarning has been triggered
|
||||
self.assertEqual(len(w), 1)
|
||||
self.assertTrue(issubclass(w[-1].category, DeprecationWarning))
|
||||
self.assertTrue("The 'embedly' filter has been renamed. Use 'embed' instead" in str(w[-1].message))
|
||||
|
||||
self.assertEqual(result, '<img src="http://www.example.com" />')
|
||||
|
||||
@patch('six.moves.urllib.request.urlopen')
|
||||
@patch('json.loads')
|
||||
def test_render_filter_nonexistent_type(self, loads, urlopen):
|
||||
urlopen.return_value = self.dummy_response
|
||||
loads.return_value = {'type': 'foo',
|
||||
'url': 'http://www.example.com'}
|
||||
temp = template.Template('{% load wagtailembeds_tags %}{{ "http://www.youtube.com/watch/"|embedly }}')
|
||||
context = template.Context()
|
||||
result = temp.render(context)
|
||||
self.assertEqual(result, '')
|
||||
|
|
|
|||
|
|
@ -170,19 +170,18 @@ class AbstractForm(Page):
|
|||
'form': form,
|
||||
})
|
||||
|
||||
def get_page_modes(self):
|
||||
return [
|
||||
('form', 'Form'),
|
||||
('landing', 'Landing page'),
|
||||
]
|
||||
preview_modes = [
|
||||
('form', 'Form'),
|
||||
('landing', 'Landing page'),
|
||||
]
|
||||
|
||||
def show_as_mode(self, mode):
|
||||
def serve_preview(self, request, mode):
|
||||
if mode == 'landing':
|
||||
return render(self.dummy_request(), self.landing_page_template, {
|
||||
return render(request, self.landing_page_template, {
|
||||
'self': self,
|
||||
})
|
||||
else:
|
||||
return super(AbstractForm, self).show_as_mode(mode)
|
||||
return super(AbstractForm, self).serve_preview(request, mode)
|
||||
|
||||
|
||||
class AbstractEmailForm(AbstractForm):
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class TestPageModes(TestCase):
|
|||
self.form_page = Page.objects.get(url_path='/home/contact-us/').specific
|
||||
|
||||
def test_form(self):
|
||||
response = self.form_page.show_as_mode('form')
|
||||
response = self.form_page.serve_preview(self.form_page.dummy_request(), 'form')
|
||||
|
||||
# Check response
|
||||
self.assertContains(response, """<label for="id_your-email">Your email</label>""")
|
||||
|
|
@ -69,7 +69,7 @@ class TestPageModes(TestCase):
|
|||
self.assertTemplateNotUsed(response, 'tests/form_page_landing.html')
|
||||
|
||||
def test_landing(self):
|
||||
response = self.form_page.show_as_mode('landing')
|
||||
response = self.form_page.serve_preview(self.form_page.dummy_request(), 'landing')
|
||||
|
||||
# Check response
|
||||
self.assertContains(response, "Thank you for your feedback.")
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from unidecode import unidecode
|
|||
|
||||
from wagtail.wagtailadmin.taggable import TagSearchable
|
||||
from wagtail.wagtailimages.backends import get_image_backend
|
||||
from wagtail.wagtailsearch import indexed
|
||||
from .utils import validate_image_format
|
||||
|
||||
|
||||
|
|
@ -48,14 +49,9 @@ class AbstractImage(models.Model, TagSearchable):
|
|||
|
||||
tags = TaggableManager(help_text=None, blank=True, verbose_name=_('Tags'))
|
||||
|
||||
indexed_fields = {
|
||||
'uploaded_by_user_id': {
|
||||
'type': 'integer',
|
||||
'store': 'yes',
|
||||
'indexed': 'no',
|
||||
'boost': 0,
|
||||
},
|
||||
}
|
||||
search_fields = TagSearchable.search_fields + (
|
||||
indexed.FilterField('uploaded_by_user'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from django.db import models
|
||||
from django.db.models.query import QuerySet
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from wagtail.wagtailsearch.indexed import Indexed
|
||||
from wagtail.wagtailsearch.utils import normalise_query_string
|
||||
|
||||
|
||||
class BaseSearch(object):
|
||||
|
|
@ -32,5 +35,38 @@ class BaseSearch(object):
|
|||
def delete(self, obj):
|
||||
return NotImplemented
|
||||
|
||||
def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]):
|
||||
def _search(self, queryset, query_string, fields=None):
|
||||
return NotImplemented
|
||||
|
||||
def search(self, query_string, model_or_queryset, fields=None, filters=None, prefetch_related=None):
|
||||
# Find model/queryset
|
||||
if isinstance(model_or_queryset, QuerySet):
|
||||
model = model_or_queryset.model
|
||||
queryset = model_or_queryset
|
||||
else:
|
||||
model = model_or_queryset
|
||||
queryset = model_or_queryset.objects.all()
|
||||
|
||||
# Model must be a descendant of Indexed and be a django model
|
||||
if not issubclass(model, Indexed) or not issubclass(model, models.Model):
|
||||
return []
|
||||
|
||||
# Normalise query string
|
||||
if query_string is not None:
|
||||
query_string = normalise_query_string(query_string)
|
||||
|
||||
# Check that theres still a query string after the clean up
|
||||
if query_string == "":
|
||||
return []
|
||||
|
||||
# Apply filters to queryset
|
||||
if filters:
|
||||
queryset = queryset.filter(**filters)
|
||||
|
||||
# Prefetch related
|
||||
if prefetch_related:
|
||||
for prefetch in prefetch_related:
|
||||
queryset = queryset.prefetch_related(prefetch)
|
||||
|
||||
# Search
|
||||
return self._search(queryset, query_string, fields=fields)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ from django.db import models
|
|||
|
||||
from wagtail.wagtailsearch.backends.base import BaseSearch
|
||||
from wagtail.wagtailsearch.indexed import Indexed
|
||||
from wagtail.wagtailsearch.utils import normalise_query_string
|
||||
|
||||
|
||||
class DBSearch(BaseSearch):
|
||||
|
|
@ -22,55 +21,38 @@ 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
|
||||
|
||||
def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]):
|
||||
# Normalise query string
|
||||
query_string = normalise_query_string(query_string)
|
||||
def _search(self, queryset, query_string, fields=None):
|
||||
if query_string is not None:
|
||||
# Get fields
|
||||
if fields is None:
|
||||
fields = [field.field_name for field in queryset.model.get_searchable_search_fields()]
|
||||
|
||||
# Get terms
|
||||
terms = query_string.split()
|
||||
if not terms:
|
||||
return model.objects.none()
|
||||
# Get terms
|
||||
terms = query_string.split()
|
||||
if not terms:
|
||||
return queryset.model.objects.none()
|
||||
|
||||
# Get fields
|
||||
if fields is None:
|
||||
fields = model.indexed_get_indexed_fields().keys()
|
||||
# Filter by terms
|
||||
for term in terms:
|
||||
term_query = models.Q()
|
||||
for field_name in fields:
|
||||
# Check if the field exists (this will filter out indexed callables)
|
||||
try:
|
||||
queryset.model._meta.get_field_by_name(field_name)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Start will all objects
|
||||
query = model.objects.all()
|
||||
# Filter on this field
|
||||
term_query |= models.Q(**{'%s__icontains' % field_name: term})
|
||||
|
||||
# Apply filters
|
||||
if filters:
|
||||
query = query.filter(**filters)
|
||||
queryset = queryset.filter(term_query)
|
||||
|
||||
# Filter by terms
|
||||
for term in terms:
|
||||
term_query = None
|
||||
for field_name in fields:
|
||||
# Check if the field exists (this will filter out indexed callables)
|
||||
try:
|
||||
model._meta.get_field_by_name(field_name)
|
||||
except:
|
||||
continue
|
||||
# Distinct
|
||||
queryset = queryset.distinct()
|
||||
|
||||
# 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)
|
||||
query = query.filter(term_query)
|
||||
|
||||
# Distinct
|
||||
query = query.distinct()
|
||||
|
||||
# Prefetch related
|
||||
if prefetch_related:
|
||||
for prefetch in prefetch_related:
|
||||
query = query.prefetch_related(prefetch)
|
||||
|
||||
return query
|
||||
return queryset
|
||||
|
|
|
|||
|
|
@ -3,21 +3,238 @@ from __future__ import absolute_import
|
|||
import json
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.sql.where import SubqueryConstraint
|
||||
|
||||
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.utils import normalise_query_string
|
||||
from wagtail.wagtailsearch.indexed import Indexed, SearchField, FilterField
|
||||
|
||||
|
||||
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 FilterError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FieldError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ElasticSearchQuery(object):
|
||||
def __init__(self, model, query_string, fields=None, filters={}):
|
||||
self.model = model
|
||||
def __init__(self, queryset, query_string, fields=None):
|
||||
self.queryset = queryset
|
||||
self.query_string = query_string
|
||||
self.fields = fields or ['_all']
|
||||
self.filters = filters
|
||||
self.fields = fields
|
||||
|
||||
def _get_filters_from_where(self, where_node):
|
||||
# Check if this is a leaf node
|
||||
if isinstance(where_node, tuple):
|
||||
field_name = where_node[0].col
|
||||
lookup = where_node[1]
|
||||
value = where_node[3]
|
||||
|
||||
# Get field
|
||||
field = dict(
|
||||
(field.get_attname(self.queryset.model), field)
|
||||
for field in self.queryset.model.get_filterable_search_fields()
|
||||
).get(field_name, None)
|
||||
|
||||
# Give error if the field doesn't exist
|
||||
if field is None:
|
||||
raise FieldError('Cannot filter ElasticSearch results with field "' + field_name + '". Please add FilterField(\'' + field_name + '\') to ' + self.queryset.model.__name__ + '.search_fields.')
|
||||
|
||||
# Get the name of the field in the index
|
||||
field_index_name = field.get_index_name(self.queryset.model)
|
||||
|
||||
# Find lookup
|
||||
if lookup == 'exact':
|
||||
if value is None:
|
||||
return {
|
||||
'missing': {
|
||||
'field': field_index_name,
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'term': {
|
||||
field_index_name: value,
|
||||
}
|
||||
}
|
||||
|
||||
if lookup == 'isnull':
|
||||
if value:
|
||||
return {
|
||||
'missing': {
|
||||
'field': field_index_name,
|
||||
}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'not': {
|
||||
'missing': {
|
||||
'field': field_index_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lookup in ['startswith', 'prefix']:
|
||||
return {
|
||||
'prefix': {
|
||||
field_index_name: value,
|
||||
}
|
||||
}
|
||||
|
||||
if lookup in ['gt', 'gte', 'lt', 'lte']:
|
||||
return {
|
||||
'range': {
|
||||
field_index_name: {
|
||||
lookup: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lookup == 'range':
|
||||
lower, upper = value
|
||||
|
||||
return {
|
||||
'range': {
|
||||
field_index_name: {
|
||||
'gte': lower,
|
||||
'lte': upper,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lookup == 'in':
|
||||
return {
|
||||
'terms': {
|
||||
field_index_name: value,
|
||||
}
|
||||
}
|
||||
|
||||
raise FilterError('Could not apply filter on ElasticSearch results: "' + field_name + '__' + lookup + ' = ' + unicode(value) + '". Lookup "' + lookup + '"" not recognosed.')
|
||||
elif isinstance(where_node, SubqueryConstraint):
|
||||
raise FilterError('Could not apply filter on ElasticSearch results: Subqueries are not allowed.')
|
||||
|
||||
# Get child filters
|
||||
connector = where_node.connector
|
||||
child_filters = [self._get_filters_from_where(child) for child in where_node.children]
|
||||
child_filters = [child_filter for child_filter in child_filters if child_filter]
|
||||
|
||||
# Connect them
|
||||
if child_filters:
|
||||
if len(child_filters) == 1:
|
||||
filter_out = child_filters[0]
|
||||
else:
|
||||
filter_out = {
|
||||
connector.lower(): [
|
||||
fil for fil in child_filters if fil is not None
|
||||
]
|
||||
}
|
||||
|
||||
if where_node.negated:
|
||||
filter_out = {
|
||||
'not': filter_out
|
||||
}
|
||||
|
||||
return filter_out
|
||||
|
||||
def _get_filters(self):
|
||||
# Filters
|
||||
|
|
@ -26,85 +243,60 @@ class ElasticSearchQuery(object):
|
|||
# Filter by content type
|
||||
filters.append({
|
||||
'prefix': {
|
||||
'content_type': self.model.indexed_get_content_type()
|
||||
'content_type': self.queryset.model.indexed_get_content_type()
|
||||
}
|
||||
})
|
||||
|
||||
# Extra filters
|
||||
if self.filters:
|
||||
for key, value in self.filters.items():
|
||||
if '__' in key:
|
||||
field, lookup = key.split('__')
|
||||
else:
|
||||
field = key
|
||||
lookup = None
|
||||
|
||||
if lookup is None:
|
||||
if value is None:
|
||||
filters.append({
|
||||
'missing': {
|
||||
'field': field,
|
||||
}
|
||||
})
|
||||
else:
|
||||
filters.append({
|
||||
'term': {
|
||||
field: value
|
||||
}
|
||||
})
|
||||
|
||||
if lookup in ['startswith', 'prefix']:
|
||||
filters.append({
|
||||
'prefix': {
|
||||
field: value
|
||||
}
|
||||
})
|
||||
|
||||
if lookup in ['gt', 'gte', 'lt', 'lte']:
|
||||
filters.append({
|
||||
'range': {
|
||||
field: {
|
||||
lookup: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if lookup == 'range':
|
||||
lower, upper = value
|
||||
filters.append({
|
||||
'range': {
|
||||
field: {
|
||||
'gte': lower,
|
||||
'lte': upper,
|
||||
}
|
||||
}
|
||||
})
|
||||
# Apply filters from queryset
|
||||
queryset_filters = self._get_filters_from_where(self.queryset.query.where)
|
||||
if queryset_filters:
|
||||
filters.append(queryset_filters)
|
||||
|
||||
return filters
|
||||
|
||||
def to_es(self):
|
||||
# Query
|
||||
query = {
|
||||
'query_string': {
|
||||
'query': self.query_string,
|
||||
}
|
||||
}
|
||||
if self.query_string is not None:
|
||||
fields = self.fields or ['_all', '_partials']
|
||||
|
||||
# Fields
|
||||
if self.fields:
|
||||
query['query_string']['fields'] = self.fields
|
||||
if len(fields) == 1:
|
||||
query = {
|
||||
'match': {
|
||||
fields[0]: self.query_string,
|
||||
}
|
||||
}
|
||||
else:
|
||||
query = {
|
||||
'multi_match': {
|
||||
'query': self.query_string,
|
||||
'fields': fields,
|
||||
}
|
||||
}
|
||||
else:
|
||||
query = {
|
||||
'match_all': {}
|
||||
}
|
||||
|
||||
# Filters
|
||||
filters = self._get_filters()
|
||||
|
||||
return {
|
||||
'filtered': {
|
||||
'query': query,
|
||||
'filter': {
|
||||
'and': filters,
|
||||
if len(filters) == 1:
|
||||
query = {
|
||||
'filtered': {
|
||||
'query': query,
|
||||
'filter': filters[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
elif len(filters) > 1:
|
||||
query = {
|
||||
'filtered': {
|
||||
'query': query,
|
||||
'filter': {
|
||||
'and': filters,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return json.dumps(self.to_es())
|
||||
|
|
@ -166,15 +358,8 @@ class ElasticSearchResults(object):
|
|||
# Initialise results dictionary
|
||||
results = dict((str(pk), None) for pk in pks)
|
||||
|
||||
# Get queryset
|
||||
queryset = self.query.model.objects.filter(pk__in=pks)
|
||||
|
||||
# Add prefetch related
|
||||
if self.prefetch_related:
|
||||
for prefetch in self.prefetch_related:
|
||||
queryset = queryset.prefetch_related(prefetch)
|
||||
|
||||
# Find objects in database and add them to dict
|
||||
queryset = self.query.queryset.filter(pk__in=pks)
|
||||
for obj in queryset:
|
||||
results[str(obj.pk)] = obj
|
||||
|
||||
|
|
@ -283,43 +468,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 +515,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 +529,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 +543,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,27 +577,18 @@ 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
|
||||
|
||||
def search(self, query_string, model, fields=None, filters={}, prefetch_related=[]):
|
||||
# Model must be a descendant of Indexed and be a django model
|
||||
if not issubclass(model, Indexed) or not issubclass(model, models.Model):
|
||||
return []
|
||||
|
||||
# Normalise query string
|
||||
query_string = normalise_query_string(query_string)
|
||||
|
||||
# Check that theres still a query string after the clean up
|
||||
if not query_string:
|
||||
return []
|
||||
|
||||
# Return search results
|
||||
return ElasticSearchResults(self, ElasticSearchQuery(model, query_string, fields=fields, filters=filters), prefetch_related=prefetch_related)
|
||||
def _search(self, queryset, query_string, fields=None):
|
||||
return ElasticSearchResults(self, ElasticSearchQuery(queryset, query_string, fields=fields))
|
||||
|
|
|
|||
|
|
@ -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,7 +33,7 @@ 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):
|
||||
|
|
@ -47,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()
|
||||
|
||||
|
|
@ -60,26 +62,119 @@ 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: isinstance(field, SearchField), cls.get_search_fields())
|
||||
|
||||
@classmethod
|
||||
def get_filterable_search_fields(cls):
|
||||
return filter(lambda field: isinstance(field, FilterField), cls.get_search_fields())
|
||||
|
||||
@classmethod
|
||||
def get_indexed_objects(cls):
|
||||
return cls.objects.all()
|
||||
|
||||
indexed_fields = ()
|
||||
|
||||
|
||||
class BaseField(object):
|
||||
suffix = ''
|
||||
|
||||
def __init__(self, field_name, **kwargs):
|
||||
self.field_name = field_name
|
||||
self.kwargs = kwargs
|
||||
|
||||
def get_field(self, cls):
|
||||
return cls._meta.get_field_by_name(self.field_name)[0]
|
||||
|
||||
def get_attname(self, cls):
|
||||
try:
|
||||
field = self.get_field(cls)
|
||||
return field.attname
|
||||
except models.fields.FieldDoesNotExist:
|
||||
return self.field_name
|
||||
|
||||
def get_index_name(self, cls):
|
||||
return self.get_attname(cls) + self.suffix
|
||||
|
||||
def get_type(self, cls):
|
||||
if 'type' in self.kwargs:
|
||||
return self.kwargs['type']
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
class FilterField(BaseField):
|
||||
suffix = '_filter'
|
||||
|
|
|
|||
|
|
@ -24,14 +24,9 @@ class Command(BaseCommand):
|
|||
toplevel_content_type = model.indexed_get_toplevel_content_type()
|
||||
|
||||
# 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
|
||||
|
||||
for obj in model.get_indexed_objects():
|
||||
# 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")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
# -*- 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):
|
||||
# Deleting model 'SearchTestChild'
|
||||
db.delete_table('wagtailsearch_searchtestchild')
|
||||
|
||||
# Deleting model 'SearchTest'
|
||||
db.delete_table('wagtailsearch_searchtest')
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Adding model 'SearchTestChild'
|
||||
db.create_table('wagtailsearch_searchtestchild', (
|
||||
('extra_content', self.gf('django.db.models.fields.TextField')()),
|
||||
('searchtest_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['wagtailsearch.SearchTest'], unique=True, primary_key=True)),
|
||||
))
|
||||
db.send_create_signal('wagtailsearch', ['SearchTestChild'])
|
||||
|
||||
# Adding model 'SearchTest'
|
||||
db.create_table('wagtailsearch_searchtest', (
|
||||
('content', self.gf('django.db.models.fields.TextField')()),
|
||||
('live', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('title', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
))
|
||||
db.send_create_signal('wagtailsearch', ['SearchTest'])
|
||||
|
||||
|
||||
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.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', [], {}),
|
||||
'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'})
|
||||
},
|
||||
'wagtailsearch.editorspick': {
|
||||
'Meta': {'ordering': "('sort_order',)", 'object_name': 'EditorsPick'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'page': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['wagtailcore.Page']"}),
|
||||
'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'editors_picks'", 'to': "orm['wagtailsearch.Query']"}),
|
||||
'sort_order': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'wagtailsearch.query': {
|
||||
'Meta': {'object_name': 'Query'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'query_string': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'})
|
||||
},
|
||||
'wagtailsearch.querydailyhits': {
|
||||
'Meta': {'unique_together': "(('query', 'date'),)", 'object_name': 'QueryDailyHits'},
|
||||
'date': ('django.db.models.fields.DateField', [], {}),
|
||||
'hits': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'query': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'daily_hits'", 'to': "orm['wagtailsearch.Query']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['wagtailsearch']
|
||||
|
|
@ -4,7 +4,7 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from wagtail.wagtailsearch.indexed import Indexed
|
||||
from wagtail.wagtailsearch import indexed
|
||||
from wagtail.wagtailsearch.utils import normalise_query_string, MAX_QUERY_STRING_LENGTH
|
||||
|
||||
|
||||
|
|
@ -76,24 +76,8 @@ class EditorsPick(models.Model):
|
|||
sort_order = models.IntegerField(null=True, blank=True, editable=False)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
def __repr__(self):
|
||||
return 'EditorsPick(query="' + self.query.query_string + '", page="' + self.page.title + '")'
|
||||
|
||||
class Meta:
|
||||
ordering = ('sort_order', )
|
||||
|
||||
|
||||
# Used for tests
|
||||
|
||||
class SearchTest(models.Model, Indexed):
|
||||
title = models.CharField(max_length=255)
|
||||
content = models.TextField()
|
||||
live = models.BooleanField(default=False)
|
||||
|
||||
indexed_fields = ("title", "content", "callable_indexed_field", "live")
|
||||
|
||||
def callable_indexed_field(self):
|
||||
return "Callable"
|
||||
|
||||
|
||||
class SearchTestChild(SearchTest):
|
||||
extra_content = models.TextField()
|
||||
|
||||
indexed_fields = "extra_content"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,18 @@
|
|||
from six import StringIO
|
||||
import warnings
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
from django.core import management
|
||||
|
||||
from wagtail.tests.utils import unittest
|
||||
from wagtail.wagtailsearch import models, get_search_backend
|
||||
from wagtail.tests.utils import unittest, WagtailTestUtils
|
||||
from wagtail.tests import models
|
||||
from wagtail.wagtailsearch.backends import get_search_backend, InvalidSearchBackendError
|
||||
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):
|
||||
class BackendTests(WagtailTestUtils):
|
||||
# To test a specific backend, subclass BackendTests and define self.backend_path.
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -41,21 +37,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 +130,7 @@ class BackendTests(object):
|
|||
|
||||
def test_delete(self):
|
||||
# Delete one of the objects
|
||||
self.backend.delete(self.testa)
|
||||
self.testa.delete()
|
||||
|
||||
# Refresh index
|
||||
|
|
@ -144,34 +145,14 @@ class BackendTests(object):
|
|||
self.backend.reset_index()
|
||||
|
||||
# Run update_index command
|
||||
management.call_command('update_index', backend=self.backend, interactive=False, stdout=StringIO())
|
||||
with self.ignore_deprecation_warnings(): # ignore any DeprecationWarnings thrown by models with old-style indexed_fields definitions
|
||||
management.call_command('update_index', backend=self.backend, interactive=False, stdout=StringIO())
|
||||
|
||||
# Check that there are still 3 results
|
||||
results = self.backend.search("Hello", models.SearchTest)
|
||||
self.assertEqual(len(results), 3)
|
||||
|
||||
|
||||
class TestDBBackend(BackendTests, TestCase):
|
||||
backend_path = 'wagtail.wagtailsearch.backends.db.DBSearch'
|
||||
|
||||
@unittest.expectedFailure
|
||||
def test_callable_indexed_field(self):
|
||||
super(TestDBBackend, self).test_callable_indexed_field()
|
||||
|
||||
|
||||
class TestElasticSearchBackend(BackendTests, TestCase):
|
||||
backend_path = 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch'
|
||||
|
||||
def test_search_with_spaces_only(self):
|
||||
# Search for some space characters and hope it doesn't crash
|
||||
results = self.backend.search(" ", models.SearchTest)
|
||||
|
||||
# Queries are lazily evaluated, force it to run
|
||||
list(results)
|
||||
|
||||
# Didn't crash, yay!
|
||||
|
||||
|
||||
@override_settings(WAGTAILSEARCH_BACKENDS={
|
||||
'default': {'BACKEND': 'wagtail.wagtailsearch.backends.db.DBSearch'}
|
||||
})
|
||||
|
|
|
|||
13
wagtail/wagtailsearch/tests/test_db_backend.py
Normal file
13
wagtail/wagtailsearch/tests/test_db_backend.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from wagtail.tests.utils import unittest
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from .test_backends import BackendTests
|
||||
|
||||
|
||||
class TestDBBackend(BackendTests, TestCase):
|
||||
backend_path = 'wagtail.wagtailsearch.backends.db.DBSearch'
|
||||
|
||||
@unittest.expectedFailure
|
||||
def test_callable_indexed_field(self):
|
||||
super(TestDBBackend, self).test_callable_indexed_field()
|
||||
|
|
@ -218,6 +218,10 @@ class TestEditorsPicksEditView(TestCase, WagtailTestUtils):
|
|||
# User should be redirected back to the index
|
||||
self.assertRedirects(response, reverse('wagtailsearch_editorspicks_index'))
|
||||
|
||||
# Check that the ordering has been saved correctly
|
||||
self.assertEqual(models.EditorsPick.objects.get(id=self.editors_pick.id).sort_order, 1)
|
||||
self.assertEqual(models.EditorsPick.objects.get(id=self.editors_pick_2.id).sort_order, 0)
|
||||
|
||||
# Check that the recommendations were reordered
|
||||
self.assertEqual(models.Query.get("Hello").editors_picks.all()[0], self.editors_pick_2)
|
||||
self.assertEqual(models.Query.get("Hello").editors_picks.all()[1], self.editors_pick)
|
||||
|
|
|
|||
399
wagtail/wagtailsearch/tests/test_elasticsearch_backend.py
Normal file
399
wagtail/wagtailsearch/tests/test_elasticsearch_backend.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
from wagtail.tests.utils import unittest
|
||||
import datetime
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.db.models import Q
|
||||
|
||||
from wagtail.tests import models
|
||||
from .test_backends import BackendTests
|
||||
|
||||
|
||||
class TestElasticSearchBackend(BackendTests, TestCase):
|
||||
backend_path = 'wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch'
|
||||
|
||||
def test_search_with_spaces_only(self):
|
||||
# Search for some space characters and hope it doesn't crash
|
||||
results = self.backend.search(" ", models.SearchTest)
|
||||
|
||||
# Queries are lazily evaluated, force it to run
|
||||
list(results)
|
||||
|
||||
# Didn't crash, yay!
|
||||
|
||||
def test_partial_search(self):
|
||||
# Reset the index
|
||||
self.backend.reset_index()
|
||||
self.backend.add_type(models.SearchTest)
|
||||
self.backend.add_type(models.SearchTestChild)
|
||||
|
||||
# Add some test data
|
||||
obj = models.SearchTest()
|
||||
obj.title = "HelloWorld"
|
||||
obj.live = True
|
||||
obj.save()
|
||||
self.backend.add(obj)
|
||||
|
||||
# Refresh the index
|
||||
self.backend.refresh_index()
|
||||
|
||||
# Search and check
|
||||
results = self.backend.search("HelloW", models.SearchTest.objects.all())
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, obj.id)
|
||||
|
||||
def test_child_partial_search(self):
|
||||
# Reset the index
|
||||
self.backend.reset_index()
|
||||
self.backend.add_type(models.SearchTest)
|
||||
self.backend.add_type(models.SearchTestChild)
|
||||
|
||||
obj = models.SearchTestChild()
|
||||
obj.title = "WorldHello"
|
||||
obj.subtitle = "HelloWorld"
|
||||
obj.live = True
|
||||
obj.save()
|
||||
self.backend.add(obj)
|
||||
|
||||
# Refresh the index
|
||||
self.backend.refresh_index()
|
||||
|
||||
# Search and check
|
||||
results = self.backend.search("HelloW", models.SearchTest.objects.all())
|
||||
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].id, obj.id)
|
||||
|
||||
|
||||
class TestElasticSearchQuery(TestCase):
|
||||
def assertDictEqual(self, a, b):
|
||||
default = self.JSONSerializer().default
|
||||
self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default))
|
||||
|
||||
def setUp(self):
|
||||
# Import using a try-catch block to prevent crashes if the elasticsearch-py
|
||||
# module is not installed
|
||||
try:
|
||||
from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchQuery
|
||||
from elasticsearch.serializer import JSONSerializer
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("elasticsearch-py not installed")
|
||||
|
||||
self.ElasticSearchQuery = ElasticSearchQuery
|
||||
self.JSONSerializer = JSONSerializer
|
||||
|
||||
def test_simple(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.all(), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_none_query_string(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.all(), None)
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'match_all': {}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_filter(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title="Test"), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'term': {'title_filter': 'Test'}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_and_filter(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title="Test", live=True), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'and': [{'term': {'live_filter': True}}, {'term': {'title_filter': 'Test'}}]}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
|
||||
# Make sure field filters are sorted (as they can be in any order which may cause false positives)
|
||||
query = query.to_es()
|
||||
field_filters = query['filtered']['filter']['and'][1]['and']
|
||||
field_filters[:] = sorted(field_filters, key=lambda f: list(f['term'].keys())[0])
|
||||
|
||||
self.assertDictEqual(query, expected_result)
|
||||
|
||||
def test_or_filter(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(Q(title="Test") | Q(live=True)), "Hello")
|
||||
|
||||
# Make sure field filters are sorted (as they can be in any order which may cause false positives)
|
||||
query = query.to_es()
|
||||
field_filters = query['filtered']['filter']['and'][1]['or']
|
||||
field_filters[:] = sorted(field_filters, key=lambda f: list(f['term'].keys())[0])
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'or': [{'term': {'live_filter': True}}, {'term': {'title_filter': 'Test'}}]}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query, expected_result)
|
||||
|
||||
def test_negated_filter(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.exclude(live=True), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'not': {'term': {'live_filter': True}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_fields(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.all(), "Hello", fields=['title'])
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'prefix': {'content_type': 'tests_searchtest'}}, 'query': {'match': {'title': 'Hello'}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_exact_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__exact="Test"), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'term': {'title_filter': 'Test'}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_none_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title=None), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'missing': {'field': 'title_filter'}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_isnull_true_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__isnull=True), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'missing': {'field': 'title_filter'}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_isnull_false_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__isnull=False), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'not': {'missing': {'field': 'title_filter'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_startswith_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(title__startswith="Test"), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'prefix': {'title_filter': 'Test'}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_gt_lookup(self):
|
||||
# This also tests conversion of python dates to strings
|
||||
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__gt=datetime.datetime(2014, 4, 29)), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'gt': '2014-04-29'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_lt_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__lt=datetime.datetime(2014, 4, 29)), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'lt': '2014-04-29'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_gte_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__gte=datetime.datetime(2014, 4, 29)), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'gte': '2014-04-29'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_lte_lookup(self):
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__lte=datetime.datetime(2014, 4, 29)), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'lte': '2014-04-29'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
def test_range_lookup(self):
|
||||
start_date = datetime.datetime(2014, 4, 29)
|
||||
end_date = datetime.datetime(2014, 8, 19)
|
||||
|
||||
# Create a query
|
||||
query = self.ElasticSearchQuery(models.SearchTest.objects.filter(published_date__range=(start_date, end_date)), "Hello")
|
||||
|
||||
# Check it
|
||||
expected_result = {'filtered': {'filter': {'and': [{'prefix': {'content_type': 'tests_searchtest'}}, {'range': {'published_date_filter': {'gte': '2014-04-29', 'lte': '2014-08-19'}}}]}, 'query': {'multi_match': {'query': 'Hello', 'fields': ['_all', '_partials']}}}}
|
||||
self.assertDictEqual(query.to_es(), expected_result)
|
||||
|
||||
|
||||
class TestElasticSearchMapping(TestCase):
|
||||
def assertDictEqual(self, a, b):
|
||||
default = self.JSONSerializer().default
|
||||
self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default))
|
||||
|
||||
def setUp(self):
|
||||
# Import using a try-catch block to prevent crashes if the elasticsearch-py
|
||||
# module is not installed
|
||||
try:
|
||||
from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchMapping
|
||||
from elasticsearch.serializer import JSONSerializer
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("elasticsearch-py not installed")
|
||||
|
||||
self.JSONSerializer = JSONSerializer
|
||||
|
||||
# Create ES mapping
|
||||
self.es_mapping = ElasticSearchMapping(models.SearchTest)
|
||||
|
||||
# Create ES document
|
||||
self.obj = models.SearchTest(title="Hello")
|
||||
self.obj.save()
|
||||
|
||||
def test_get_document_type(self):
|
||||
self.assertEqual(self.es_mapping.get_document_type(), 'tests_searchtest')
|
||||
|
||||
def test_get_mapping(self):
|
||||
# Build mapping
|
||||
mapping = self.es_mapping.get_mapping()
|
||||
|
||||
# Check
|
||||
expected_result = {
|
||||
'tests_searchtest': {
|
||||
'properties': {
|
||||
'pk': {'index': 'not_analyzed', 'type': 'string', 'store': 'yes', 'include_in_all': False},
|
||||
'content_type': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False},
|
||||
'_partials': {'analyzer': 'edgengram_analyzer', 'include_in_all': False, 'type': 'string'},
|
||||
'live_filter': {'index': 'not_analyzed', 'type': 'boolean', 'include_in_all': False},
|
||||
'published_date_filter': {'index': 'not_analyzed', 'type': 'date', 'include_in_all': False},
|
||||
'title': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'},
|
||||
'title_filter': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False},
|
||||
'content': {'type': 'string', 'include_in_all': True},
|
||||
'callable_indexed_field': {'type': 'string', 'include_in_all': True}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertDictEqual(mapping, expected_result)
|
||||
|
||||
def test_get_document_id(self):
|
||||
self.assertEqual(self.es_mapping.get_document_id(self.obj), 'tests_searchtest:' + str(self.obj.pk))
|
||||
|
||||
def test_get_document(self):
|
||||
# Get document
|
||||
document = self.es_mapping.get_document(self.obj)
|
||||
|
||||
# Check
|
||||
expected_result = {
|
||||
'pk': str(self.obj.pk),
|
||||
'content_type': 'tests_searchtest',
|
||||
'_partials': ['Hello'],
|
||||
'live_filter': False,
|
||||
'published_date_filter': None,
|
||||
'title': 'Hello',
|
||||
'title_filter': 'Hello',
|
||||
'callable_indexed_field': 'Callable',
|
||||
'content': '',
|
||||
}
|
||||
|
||||
self.assertDictEqual(document, expected_result)
|
||||
|
||||
|
||||
class TestElasticSearchMappingInheritance(TestCase):
|
||||
def assertDictEqual(self, a, b):
|
||||
default = self.JSONSerializer().default
|
||||
self.assertEqual(json.dumps(a, sort_keys=True, default=default), json.dumps(b, sort_keys=True, default=default))
|
||||
|
||||
def setUp(self):
|
||||
# Import using a try-catch block to prevent crashes if the elasticsearch-py
|
||||
# module is not installed
|
||||
try:
|
||||
from wagtail.wagtailsearch.backends.elasticsearch import ElasticSearchMapping
|
||||
from elasticsearch.serializer import JSONSerializer
|
||||
except ImportError:
|
||||
raise unittest.SkipTest("elasticsearch-py not installed")
|
||||
|
||||
self.JSONSerializer = JSONSerializer
|
||||
|
||||
# Create ES mapping
|
||||
self.es_mapping = ElasticSearchMapping(models.SearchTestChild)
|
||||
|
||||
# Create ES document
|
||||
self.obj = models.SearchTestChild(title="Hello", subtitle="World")
|
||||
self.obj.save()
|
||||
|
||||
def test_get_document_type(self):
|
||||
self.assertEqual(self.es_mapping.get_document_type(), 'tests_searchtest_tests_searchtestchild')
|
||||
|
||||
def test_get_mapping(self):
|
||||
# Build mapping
|
||||
mapping = self.es_mapping.get_mapping()
|
||||
|
||||
# Check
|
||||
expected_result = {
|
||||
'tests_searchtest_tests_searchtestchild': {
|
||||
'properties': {
|
||||
# New
|
||||
'extra_content': {'type': 'string', 'include_in_all': True},
|
||||
'subtitle': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'},
|
||||
|
||||
# Inherited
|
||||
'pk': {'index': 'not_analyzed', 'type': 'string', 'store': 'yes', 'include_in_all': False},
|
||||
'content_type': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False},
|
||||
'_partials': {'analyzer': 'edgengram_analyzer', 'include_in_all': False, 'type': 'string'},
|
||||
'live_filter': {'index': 'not_analyzed', 'type': 'boolean', 'include_in_all': False},
|
||||
'published_date_filter': {'index': 'not_analyzed', 'type': 'date', 'include_in_all': False},
|
||||
'title': {'type': 'string', 'include_in_all': True, 'analyzer': 'edgengram_analyzer'},
|
||||
'title_filter': {'index': 'not_analyzed', 'type': 'string', 'include_in_all': False},
|
||||
'content': {'type': 'string', 'include_in_all': True},
|
||||
'callable_indexed_field': {'type': 'string', 'include_in_all': True}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertDictEqual(mapping, expected_result)
|
||||
|
||||
def test_get_document_id(self):
|
||||
# This must be tests_searchtest instead of 'tests_searchtest_tests_searchtestchild'
|
||||
# as it uses the contents base content type name.
|
||||
# This prevents the same object being accidentally indexed twice.
|
||||
self.assertEqual(self.es_mapping.get_document_id(self.obj), 'tests_searchtest:' + str(self.obj.pk))
|
||||
|
||||
def test_get_document(self):
|
||||
# Build document
|
||||
document = self.es_mapping.get_document(self.obj)
|
||||
|
||||
# Sort partials
|
||||
if '_partials' in document:
|
||||
document['_partials'].sort()
|
||||
|
||||
# Check
|
||||
expected_result = {
|
||||
# New
|
||||
'extra_content': '',
|
||||
'subtitle': 'World',
|
||||
|
||||
# Changed
|
||||
'content_type': 'tests_searchtest_tests_searchtestchild',
|
||||
|
||||
# Inherited
|
||||
'pk': str(self.obj.pk),
|
||||
'_partials': ['Hello', 'World'],
|
||||
'live_filter': False,
|
||||
'published_date_filter': None,
|
||||
'title': 'Hello',
|
||||
'title_filter': 'Hello',
|
||||
'callable_indexed_field': 'Callable',
|
||||
'content': '',
|
||||
}
|
||||
|
||||
self.assertDictEqual(document, expected_result)
|
||||
53
wagtail/wagtailsearch/tests/test_indexed_class.py
Normal file
53
wagtail/wagtailsearch/tests/test_indexed_class.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import warnings
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from wagtail.wagtailsearch import indexed
|
||||
from wagtail.tests import models
|
||||
from wagtail.tests.utils import WagtailTestUtils
|
||||
|
||||
|
||||
class TestContentTypeNames(TestCase):
|
||||
def test_base_content_type_name(self):
|
||||
name = models.SearchTestChild.indexed_get_toplevel_content_type()
|
||||
self.assertEqual(name, 'tests_searchtest')
|
||||
|
||||
def test_qualified_content_type_name(self):
|
||||
name = models.SearchTestChild.indexed_get_content_type()
|
||||
self.assertEqual(name, 'tests_searchtest_tests_searchtestchild')
|
||||
|
||||
|
||||
class TestIndexedFieldsBackwardsCompatibility(TestCase, WagtailTestUtils):
|
||||
def test_indexed_fields_backwards_compatibility(self):
|
||||
# Get search fields
|
||||
with self.ignore_deprecation_warnings():
|
||||
search_fields = models.SearchTestOldConfig.get_search_fields()
|
||||
|
||||
search_fields_dict = dict(
|
||||
((field.field_name, type(field)), field)
|
||||
for field in search_fields
|
||||
)
|
||||
|
||||
# Check that the fields were found
|
||||
self.assertEqual(len(search_fields_dict), 2)
|
||||
self.assertIn(('title', indexed.SearchField), search_fields_dict.keys())
|
||||
self.assertIn(('live', indexed.FilterField), search_fields_dict.keys())
|
||||
|
||||
# Check that the title field has the correct settings
|
||||
self.assertTrue(search_fields_dict[('title', indexed.SearchField)].partial_match)
|
||||
self.assertEqual(search_fields_dict[('title', indexed.SearchField)].boost, 100)
|
||||
|
||||
def test_indexed_fields_backwards_compatibility_list(self):
|
||||
# Get search fields
|
||||
with self.ignore_deprecation_warnings():
|
||||
search_fields = models.SearchTestOldConfigList.get_search_fields()
|
||||
|
||||
search_fields_dict = dict(
|
||||
((field.field_name, type(field)), field)
|
||||
for field in search_fields
|
||||
)
|
||||
|
||||
# Check that the fields were found
|
||||
self.assertEqual(len(search_fields_dict), 2)
|
||||
self.assertIn(('title', indexed.SearchField), search_fields_dict.keys())
|
||||
self.assertIn(('content', indexed.SearchField), search_fields_dict.keys())
|
||||
|
|
@ -0,0 +1 @@
|
|||
from wagtail.wagtailsearch.urls.frontend import urlpatterns
|
||||
|
|
@ -55,6 +55,9 @@ def save_editorspicks(query, new_query, editors_pick_formset):
|
|||
for i, form in enumerate(editors_pick_formset.ordered_forms):
|
||||
form.instance.sort_order = i
|
||||
|
||||
# Make sure the form is marked as changed so it gets saved with the new order
|
||||
form.has_changed = lambda: True
|
||||
|
||||
editors_pick_formset.save()
|
||||
|
||||
# If query was changed, move all editors picks to the new query
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ from wagtail.tests.utils import WagtailTestUtils
|
|||
from wagtail.tests.models import Advert, AlphaSnippet, ZuluSnippet
|
||||
from wagtail.wagtailsnippets.models import register_snippet, SNIPPET_MODELS
|
||||
|
||||
from wagtail.wagtailsnippets.views.snippets import get_content_type_from_url_params, get_snippet_edit_handler
|
||||
from wagtail.wagtailsnippets.views.snippets import (
|
||||
get_snippet_edit_handler
|
||||
)
|
||||
from wagtail.wagtailsnippets.edit_handlers import SnippetChooserPanel
|
||||
|
||||
|
||||
class TestSnippetIndexView(TestCase, WagtailTestUtils):
|
||||
def setUp(self):
|
||||
self.login()
|
||||
|
|
@ -77,12 +80,10 @@ class TestSnippetCreateView(TestCase, WagtailTestUtils):
|
|||
|
||||
|
||||
class TestSnippetEditView(TestCase, WagtailTestUtils):
|
||||
def setUp(self):
|
||||
self.test_snippet = Advert()
|
||||
self.test_snippet.text = 'test_advert'
|
||||
self.test_snippet.url = 'http://www.example.com/'
|
||||
self.test_snippet.save()
|
||||
fixtures = ['wagtail/tests/fixtures/test.json']
|
||||
|
||||
def setUp(self):
|
||||
self.test_snippet = Advert.objects.get(id=1)
|
||||
self.login()
|
||||
|
||||
def get(self, params={}):
|
||||
|
|
@ -126,12 +127,10 @@ class TestSnippetEditView(TestCase, WagtailTestUtils):
|
|||
|
||||
|
||||
class TestSnippetDelete(TestCase, WagtailTestUtils):
|
||||
def setUp(self):
|
||||
self.test_snippet = Advert()
|
||||
self.test_snippet.text = 'test_advert'
|
||||
self.test_snippet.url = 'http://www.example.com/'
|
||||
self.test_snippet.save()
|
||||
fixtures = ['wagtail/tests/fixtures/test.json']
|
||||
|
||||
def setUp(self):
|
||||
self.test_snippet = Advert.objects.get(id=1)
|
||||
self.login()
|
||||
|
||||
def test_delete_get(self):
|
||||
|
|
@ -150,14 +149,11 @@ class TestSnippetDelete(TestCase, WagtailTestUtils):
|
|||
|
||||
|
||||
class TestSnippetChooserPanel(TestCase):
|
||||
def setUp(self):
|
||||
content_type = get_content_type_from_url_params('tests',
|
||||
'advert')
|
||||
fixtures = ['wagtail/tests/fixtures/test.json']
|
||||
|
||||
test_snippet = Advert()
|
||||
test_snippet.text = 'test_advert'
|
||||
test_snippet.url = 'http://www.example.com/'
|
||||
test_snippet.save()
|
||||
def setUp(self):
|
||||
content_type = Advert
|
||||
test_snippet = Advert.objects.get(id=1)
|
||||
|
||||
edit_handler_class = get_snippet_edit_handler(Advert)
|
||||
form_class = edit_handler_class.get_form_class(Advert)
|
||||
|
|
@ -174,7 +170,7 @@ class TestSnippetChooserPanel(TestCase):
|
|||
self.assertTrue('test_advert' in self.snippet_chooser_panel.render_as_field())
|
||||
|
||||
def test_render_js(self):
|
||||
self.assertTrue("createSnippetChooser(fixPrefix('id_text'), 'contenttypes/contenttype');"
|
||||
self.assertTrue("createSnippetChooser(fixPrefix('id_text'), 'tests/advert');"
|
||||
in self.snippet_chooser_panel.render_js())
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue