diff --git a/.jscsrc b/.jscsrc index 877f8f1d4..010eaa554 100644 --- a/.jscsrc +++ b/.jscsrc @@ -12,7 +12,7 @@ "**/*.min.js", "**/vendor/**/*.js", "./wagtail/wagtailadmin/templates/wagtailadmin/edit_handlers/inline_panel.js", - "./wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.js", + "./wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js", "./wagtail/wagtailusers/templates/wagtailusers/groups/includes/page_permissions_formset.js", "./wagtail/wagtailsnippets/templates/wagtailsnippets/chooser/chosen.js", "./wagtail/wagtailimages/templates/wagtailimages/chooser/image_chosen.js", diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 371a7c348..277d15c61 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -5,13 +5,23 @@ Changelog ~~~~~~~~~~~~~~~~ * Implemented the `specific()` method on PageQuerySet, to return pages as their most specific type + * "Promoted search results" has moved into its own module + * Elasticsearch backend now supports an experimental `ATOMIC_REBUILD` flag to keep the existing index available while the `update_index` task is running + * The wagtailapi module has been refactored to use Django REST Framework * Implemented pagination in the page chooser modal + * Changed INSTALLED_APPS in project template to list apps in precedence order (Piet Delport) * The `{% image %}` tag now supports filters on the image variable, e.g. `{% image primary_img|default:secondary_img width-500 %}` * Moved the style guide menu item into the Settings sub-menu * Search backends can now be specified by module (e.g. `wagtail.wagtailsearch.backends.elasticsearch`), rather than a specific class (`wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch`) * Added ``descendant_of`` filter to the API (Michael Fillier) * Added optional directory argument to "wagtail start" command (Mitchel Cabuloy) + * Non-superusers can now view/edit/delete sites if they have the correct permissions + * Image file size is now stored in the database, to avoid unnecessary filesystem lookups + * Updated URLs within the admin backend to use namespaces + * The `update_index` task now indexes objects in batches of 1000, to indicate progress and avoid excessive memory use * Fix: Text areas in the non-default tab of the page editor now resize to the correct height + * Fix: Tabs in "insert link" modal in the rich text editor no longer disappear (Tim Heap) + * Fix: H2 elements in rich text fields were accidentally given a click() binding when put insite a collapsible multi field panel 1.0 (16.07.2015) diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 9e0b3068b..2f1ad8aa2 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -56,6 +56,8 @@ Contributors * Nar Chhantyal * Michael Fillier * Mitchel Cabuloy +* Piet Delport +* Tom Christie Translators =========== diff --git a/docs/getting_started/tutorial.rst b/docs/getting_started/tutorial.rst index c48072702..ee9500e9a 100644 --- a/docs/getting_started/tutorial.rst +++ b/docs/getting_started/tutorial.rst @@ -147,7 +147,7 @@ Create a template at ``blog/templates/blog/blog_page.html``: {% load wagtailcore_tags %} - {% block body_class %}templage-blogpage{% endblock %} + {% block body_class %}template-blogpage{% endblock %} {% block content %}

{{ self.title }}

@@ -217,7 +217,7 @@ Adjust your blog page template to include the image: {% load wagtailcore_tags wagtailimages_tags %} - {% block body_class %}templage-blogpage{% endblock %} + {% block body_class %}template-blogpage{% endblock %} {% block content %}

{{ self.title }}

diff --git a/docs/reference/contrib/index.rst b/docs/reference/contrib/index.rst index cfd4f46f3..7996d83dc 100644 --- a/docs/reference/contrib/index.rst +++ b/docs/reference/contrib/index.rst @@ -13,6 +13,7 @@ Wagtail ships with a variety of extra optional modules. frontendcache routablepage api/index + searchpromotions :doc:`forms` @@ -49,3 +50,9 @@ Provides a way of embedding Django URLconfs into pages. ---------------- A module for adding a read only, JSON based web API to your Wagtail site + + +:doc:`searchpromotions` +----------------------- + +A module for managing "Promoted Search Results" diff --git a/docs/reference/contrib/searchpromotions.rst b/docs/reference/contrib/searchpromotions.rst new file mode 100644 index 000000000..2f888b81f --- /dev/null +++ b/docs/reference/contrib/searchpromotions.rst @@ -0,0 +1,63 @@ +.. _editors-picks: + +======================= +Promoted search results +======================= + +.. module:: wagtail.contrib.wagtailsearchpromotions + +.. versionchanged:: 1.1 + + Before Wagtail 1.1, promoted search results were implemented in the :mod:`wagtail.wagtailsearch` core module and called "editors picks". + +The ``searchpromotions`` module provides the models and user interface for managing "Promoted search results" and displaying them in a search results page. + +"Promoted search results" allow editors to explicitly link relevant content to search terms, so results pages can contain curated content in addition to results from the search engine. + + +Installation +============ + +The ``searchpromotions`` module is not enabled by default. To install it, add ``wagtail.contrib.wagtailsearchpromotions`` to ``INSTALLED_APPS`` in your project's Django settings file. + + +.. code-block:: python + + INSTALLED_APPS = [ + ... + + 'wagtail.contrib.wagtailsearchpromotions', + ] + +This app contains migrations so make sure you run the ``migrate`` django-admin command after installing. + + +Usage +===== + +Once installed, a new menu item called "Promoted search results" should appear in the "Settings" menu. This is where you can assign pages to popular search terms. + + +Displaying on a search results page +----------------------------------- + +To retrieve a list of promoted search results for a particular search query, you can use the ``{% get_search_promotions %}`` template tag from the ``wagtailsearchpromotions_tags`` templatetag library: + +.. code-block:: HTML+Django + + {% load wagtailcore_tags wagtailsearchpromotions_tags %} + + ... + + {% get_search_promotions search_query as search_promotions %} + + diff --git a/docs/releases/1.1.rst b/docs/releases/1.1.rst index fc8fbd822..2a227a6b3 100644 --- a/docs/releases/1.1.rst +++ b/docs/releases/1.1.rst @@ -15,17 +15,56 @@ What's new Usually, an operation that retrieves a queryset of pages (such as ``homepage.get_children()``) will return them as basic Page instances, which only include the core page data such as title. The ``specific()`` method (e.g. ``homepage.get_children().specific()``) now allows them to be retrieved as their most specific type, using the minimum number of queries. +"Promoted search results" has moved into its own module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Previously, this was implemented in :mod:`~wagtail.wagtailsearch` but now has +been moved into a separate module: :mod:`wagtail.contrib.wagtailsearchpromotions` + +Atomic rebuilding of Elasticsearch indexes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The Elasticsearch search backend now accepts an experimental ``ATOMIC_REBUILD`` flag which ensures that the existing search index continues to be available while the ``update_index`` task is running. See :ref:`wagtailsearch_backends_atomic_rebuild`. + Minor features ~~~~~~~~~~~~~~ + * The :mod:`~wagtail.contrib.wagtailapi` module has been refactored to use Django REST Framework * Implemented pagination in the page chooser modal + * Changed INSTALLED_APPS in project template to list apps in precedence order * The ``{% image %}`` tag now supports filters on the image variable, e.g. ``{% image primary_img|default:secondary_img width-500 %}`` * Moved the style guide menu item into the Settings sub-menu * Search backends can now be specified by module (e.g. ``wagtail.wagtailsearch.backends.elasticsearch``), rather than a specific class (``wagtail.wagtailsearch.backends.elasticsearch.ElasticSearch``) * Added ``descendant_of`` filter to the API * Added optional directory argument to "wagtail start" command + * Non-superusers can now view/edit/delete sites if they have the correct permissions + * Image file size is now stored in the database, to avoid unnecessary filesystem lookups + * Updated URLs within the admin backend to use namespaces + * The ``update_index`` task now indexes objects in batches of 1000, to indicate progress and avoid excessive memory use Bug fixes ~~~~~~~~~ * Text areas in the non-default tab of the page editor now resize to the correct height + * Tabs in "insert link" modal in the rich text editor no longer disappear (Tim Heap) + * H2 elements in rich text fields were accidentally given a click() binding when put insite a collapsible multi field panel + + +Upgrade considerations +====================== + +"Promoted search results" no longer in :mod:`~wagtail.wagtailsearch` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This feature has moved into a contrib module so is no longer enabled by default. + +To re-enable it, add :mod:`wagtail.contrib.wagtailsearchpromotions` to your ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS = [ + ... + + 'wagtail.contrib.wagtailsearchpromotions', + + ... diff --git a/docs/topics/search/backends.rst b/docs/topics/search/backends.rst index 1e0a956ad..319f4b2a9 100644 --- a/docs/topics/search/backends.rst +++ b/docs/topics/search/backends.rst @@ -41,6 +41,22 @@ The ``AUTO_UPDATE`` setting allows you to disable this on a per-index basis: If you have disabled auto update, you must run the :ref:`update_index` command on a regular basis to keep the index in sync with the database. +.. _wagtailsearch_backends_atomic_rebuild: + +``ATOMIC_REBUILD`` +================== + +.. versionadded:: 1.1 + +By default (when using the Elasticsearch backend), when the ``update_index`` command is run, Wagtail deletes the index and rebuilds it from scratch. This causes the search engine to not return results until the rebuild is complete and is also risky as you can't rollback if an error occurs. + +Setting the ``ATOMIC_REBUILD`` setting to ``True`` makes Wagtail rebuild into a separate index while keep the old index active until the new one is fully built. When the rebuild is finished, the indexes are swapped atomically and the old index is deleted. + +.. warning:: Experimental feature + + This feature is currently experimental. Please use it with caution. + + ``BACKEND`` =========== diff --git a/docs/topics/search/searching.rst b/docs/topics/search/searching.rst index 421d57301..6475855c4 100644 --- a/docs/topics/search/searching.rst +++ b/docs/topics/search/searching.rst @@ -100,28 +100,12 @@ And here's a template to go with it: {% endblock %} -.. _editors-picks: +Promoted search results +======================= +"Promoted search results" allow editors to explicitly link relevant content to search terms, so results pages can contain curated content in addition to results from the search engine. -Editor's picks -============== - -Editor's picks are a way of explicitly linking relevant content to search terms, so results pages can contain curated content in addition to results from the search algorithm. - -You can get a list of editors picks for a particular query using the ``Query`` class: - -.. code-block:: python - - editors_picks = Query.get(search_query).editors_picks.all() - - -Each editors pick contains the following fields: - - ``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. - - ``description`` - The description entered when choosing the pick, perhaps explaining why the page is relevant to the search terms. +This functionality is provided by the :mod:`~wagtail.contrib.wagtailsearchpromotions` contrib module. Searching Images, Documents and custom models diff --git a/setup.py b/setup.py index 73849d488..bd8fb70fc 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,7 @@ install_requires = [ "django-modelcluster>=0.6", "django-taggit>=0.13.0", "django-treebeard==3.0", + "djangorestframework==3.1.3", "Pillow>=2.6.1", "beautifulsoup4>=4.3.2", "html5lib==0.999", diff --git a/tox.ini b/tox.ini index 0fb33a698..20c41ea85 100644 --- a/tox.ini +++ b/tox.ini @@ -22,6 +22,7 @@ deps = django-taggit==0.13.0 django-treebeard==3.0 django-sendfile==0.3.6 + djangorestframework==3.1.3 Pillow>=2.3.0 beautifulsoup4>=4.3.2 html5lib==0.999 diff --git a/wagtail/contrib/wagtailapi/api.py b/wagtail/contrib/wagtailapi/api.py deleted file mode 100644 index 586e3d524..000000000 --- a/wagtail/contrib/wagtailapi/api.py +++ /dev/null @@ -1,95 +0,0 @@ -import json -from functools import wraps - -from django.conf.urls import url, include -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, Http404 -from django.core.serializers.json import DjangoJSONEncoder -from django.core.urlresolvers import reverse - -from taggit.managers import _TaggableManager -from taggit.models import Tag - -from wagtail.utils.urlpatterns import decorate_urlpatterns -from wagtail.wagtailcore.blocks import StreamValue - -from .endpoints import URLPath, ObjectDetailURL, PagesAPIEndpoint, ImagesAPIEndpoint, DocumentsAPIEndpoint -from .utils import BadRequestError, get_base_url - - -def get_full_url(request, path): - base_url = get_base_url(request) or '' - return base_url + path - - -class API(object): - def __init__(self, endpoints): - self.endpoints = endpoints - - def find_model_detail_view(self, model): - for endpoint_name, endpoint in self.endpoints.items(): - if endpoint.has_model(model): - return 'wagtailapi_v1:%s:detail' % endpoint_name - - def make_response(self, request, data, response_cls=HttpResponse): - api = self - - class WagtailAPIJSONEncoder(DjangoJSONEncoder): - def default(self, o): - if isinstance(o, _TaggableManager): - return list(o.all()) - elif isinstance(o, Tag): - return o.name - elif isinstance(o, URLPath): - return get_full_url(request, o.path) - elif isinstance(o, ObjectDetailURL): - view = api.find_model_detail_view(o.model) - - if view: - return get_full_url(request, reverse(view, args=(o.pk, ))) - else: - return None - elif isinstance(o, StreamValue): - return o.stream_block.get_prep_value(o) - else: - return super(WagtailAPIJSONEncoder, self).default(o) - - return response_cls( - json.dumps(data, indent=4, cls=WagtailAPIJSONEncoder), - content_type='application/json' - ) - - def api_view(self, view): - """ - This is a decorator that is applied to all API views. - - It is responsible for serialising the responses from the endpoints - and handling errors. - """ - @wraps(view) - def wrapper(request, *args, **kwargs): - # Catch exceptions and format them as JSON documents - try: - return self.make_response(request, view(request, *args, **kwargs)) - except Http404 as e: - return self.make_response(request, { - 'message': str(e) - }, response_cls=HttpResponseNotFound) - except BadRequestError as e: - return self.make_response(request, { - 'message': str(e) - }, response_cls=HttpResponseBadRequest) - - return wrapper - - def get_urlpatterns(self): - return decorate_urlpatterns([ - url(r'^%s/' % name, include(endpoint.get_urlpatterns(), namespace=name)) - for name, endpoint in self.endpoints.items() - ], self.api_view) - - -v1 = API({ - 'pages': PagesAPIEndpoint(), - 'images': ImagesAPIEndpoint(), - 'documents': DocumentsAPIEndpoint(), -}) diff --git a/wagtail/contrib/wagtailapi/endpoints.py b/wagtail/contrib/wagtailapi/endpoints.py index 318c2c3c4..a2cd7931b 100644 --- a/wagtail/contrib/wagtailapi/endpoints.py +++ b/wagtail/contrib/wagtailapi/endpoints.py @@ -1,100 +1,34 @@ from __future__ import absolute_import -from collections import OrderedDict - -from modelcluster.models import get_all_child_relations -from taggit.managers import _TaggableManager - -from django.db import models -from django.utils.encoding import force_text -from django.shortcuts import get_object_or_404 from django.conf.urls import url -from django.conf import settings +from django.http import Http404 + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet from wagtail.wagtailcore.models import Page from wagtail.wagtailimages.models import get_image_model from wagtail.wagtaildocs.models import Document from wagtail.wagtailcore.utils import resolve_model_string -from wagtail.wagtailsearch.backends import get_search_backend -from wagtail.utils.compat import get_related_model +from .filters import ( + FieldsFilter, OrderingFilter, SearchFilter, + ChildOfFilter, DescendantOfFilter +) +from .renderers import WagtailJSONRenderer +from .pagination import WagtailPagination +from .serializers import WagtailSerializer, PageSerializer, DocumentSerializer from .utils import BadRequestError -class URLPath(object): - """ - This class represents a URL path that should be converted to a full URL. +class BaseAPIEndpoint(GenericViewSet): + renderer_classes = [WagtailJSONRenderer] + pagination_class = WagtailPagination + serializer_class = WagtailSerializer + filter_classes = [] + queryset = None # Set on subclasses or implement `get_queryset()`. - It is used when the domain that should be used is not known at the time - the URL was generated. It will get resolved to a full URL during - serialisation in api.py. - - One example use case is the documents endpoint adding download URLs into - the JSON. The endpoint does not know the domain name to use at the time so - returns one of these instead. - """ - def __init__(self, path): - self.path = path - - -class ObjectDetailURL(object): - def __init__(self, model, pk): - self.model = model - self.pk = pk - - -def get_api_data(obj, fields): - # Find any child relations (pages only) - child_relations = {} - if isinstance(obj, Page): - child_relations = { - child_relation.field.rel.related_name: get_related_model(child_relation) - for child_relation in get_all_child_relations(type(obj)) - } - - # Loop through fields - for field_name in fields: - # Check child relations - if field_name in child_relations and hasattr(child_relations[field_name], 'api_fields'): - yield field_name, [ - dict(get_api_data(child_object, child_relations[field_name].api_fields)) - for child_object in getattr(obj, field_name).all() - ] - continue - - # Check django fields - try: - field = obj._meta.get_field(field_name) - - if field.rel and isinstance(field.rel, models.ManyToOneRel): - # Foreign key - val = field._get_val_from_obj(obj) - - if val: - yield field_name, OrderedDict([ - ('id', field._get_val_from_obj(obj)), - ('meta', OrderedDict([ - ('type', field.rel.to._meta.app_label + '.' + field.rel.to.__name__), - ('detail_url', ObjectDetailURL(field.rel.to, val)), - ])), - ]) - else: - yield field_name, None - else: - yield field_name, field._get_val_from_obj(obj) - - continue - except models.fields.FieldDoesNotExist: - pass - - # Check attributes - if hasattr(obj, field_name): - value = getattr(obj, field_name) - yield field_name, force_text(value, strings_only=True) - continue - - -class BaseAPIEndpoint(object): known_query_parameters = frozenset([ 'limit', 'offset', @@ -102,76 +36,48 @@ class BaseAPIEndpoint(object): 'order', 'search', ]) + extra_api_fields = [] + name = None # Set on subclass. def listing_view(self, request): - return NotImplemented + queryset = self.get_queryset() + self.check_query_parameters(queryset) + queryset = self.filter_queryset(queryset) + queryset = self.paginate_queryset(queryset) + serializer = self.get_serializer(queryset, many=True) + return self.get_paginated_response(serializer.data) def detail_view(self, request, pk): - return NotImplemented + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def handle_exception(self, exc): + if isinstance(exc, Http404): + data = {'message': str(exc)} + return Response(data, status=status.HTTP_404_NOT_FOUND) + elif isinstance(exc, BadRequestError): + data = {'message': str(exc)} + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return super(BaseAPIEndpoint, self).handle_exception(exc) def get_api_fields(self, model): """ This returns a list of field names that are allowed to be used in the API (excluding the id field). """ - api_fields = [] + api_fields = self.extra_api_fields[:] if hasattr(model, 'api_fields'): api_fields.extend(model.api_fields) return api_fields - def serialize_object_metadata(self, request, obj, show_details=False): + def check_query_parameters(self, queryset): """ - This returns a JSON-serialisable dict to use for the "meta" - section of a particlular object. + Ensure that only valid query paramters are included in the URL. """ - data = OrderedDict() - - # Add type - data['type'] = type(obj)._meta.app_label + '.' + type(obj).__name__ - data['detail_url'] = ObjectDetailURL(type(obj), obj.pk) - - return data - - def serialize_object(self, request, obj, fields=frozenset(), extra_data=(), all_fields=False, show_details=False): - """ - This converts an object into JSON-serialisable dict so it can - be used in the API. - """ - data = [ - ('id', obj.id), - ] - - # Add meta - metadata = self.serialize_object_metadata(request, obj, show_details=show_details) - if metadata: - data.append(('meta', metadata)) - - # Add extra data - data.extend(extra_data) - - # Add other fields - api_fields = self.get_api_fields(type(obj)) - api_fields = list(OrderedDict.fromkeys(api_fields)) # Removes any duplicates in case the user put "title" in api_fields - - if all_fields: - fields = api_fields - else: - unknown_fields = fields - set(api_fields) - - if unknown_fields: - raise BadRequestError("unknown fields: %s" % ', '.join(sorted(unknown_fields))) - - # Reorder fields so it matches the order of api_fields - fields = [field for field in api_fields if field in fields] - - data.extend(get_api_data(obj, fields)) - - return OrderedDict(data) - - def check_query_parameters(self, request, queryset): - query_parameters = set(request.GET.keys()) + query_parameters = set(self.request.GET.keys()) # All query paramters must be either a field or an operation allowed_query_parameters = set(self.get_api_fields(queryset.model)).union(self.known_query_parameters).union({'id'}) @@ -179,147 +85,87 @@ class BaseAPIEndpoint(object): if unknown_parameters: raise BadRequestError("query parameter is not an operation or a recognised field: %s" % ', '.join(sorted(unknown_parameters))) - def do_field_filtering(self, request, queryset): + def get_serializer_context(self): """ - This performs field level filtering on the result set - Eg: ?title=James Joyce + The serialization context differs between listing and detail views. """ - fields = set(self.get_api_fields(queryset.model)).union({'id'}) + request = self.request + if self.action == 'listing_view': - for field_name, value in request.GET.items(): - if field_name in fields: - field = getattr(queryset.model, field_name, None) - - if isinstance(field, _TaggableManager): - for tag in value.split(','): - queryset = queryset.filter(**{field_name + '__name': tag}) - - # Stick a message on the queryset to indicate that tag filtering has been performed - # This will let the do_search method know that it must raise an error as searching - # and tag filtering at the same time is not supported - queryset._filtered_by_tag = True - else: - queryset = queryset.filter(**{field_name: value}) - - return queryset - - def do_ordering(self, request, queryset): - """ - This applies ordering to the result set - Eg: ?order=title - - It also supports reverse ordering - Eg: ?order=-title - - And random ordering - Eg: ?order=random - """ - if 'order' in request.GET: - # Prevent ordering while searching - if 'search' in request.GET: - raise BadRequestError("ordering with a search query is not supported") - - order_by = request.GET['order'] - - # Random ordering - if order_by == 'random': - # Prevent ordering by random with offset - if 'offset' in request.GET: - raise BadRequestError("random ordering with offset is not supported") - - return queryset.order_by('?') - - # Check if reverse ordering is set - if order_by.startswith('-'): - reverse_order = True - order_by = order_by[1:] + if 'fields' in request.GET: + fields = set(request.GET['fields'].split(',')) else: - reverse_order = False + fields = {'title'} - # Add ordering - if order_by == 'id' or order_by in self.get_api_fields(queryset.model): - queryset = queryset.order_by(order_by) - else: - # Unknown field - raise BadRequestError("cannot order by '%s' (unknown field)" % order_by) + return { + 'request': request, + 'view': self, + 'fields': fields + } - # Reverse order - if reverse_order: - queryset = queryset.reverse() + return { + 'request': request, + 'view': self, + 'all_fields': True, + 'show_details': True + } - return queryset + def get_renderer_context(self): + context = super(BaseAPIEndpoint, self).get_renderer_context() + context['endpoints'] = [ + PagesAPIEndpoint, + ImagesAPIEndpoint, + DocumentsAPIEndpoint + ] + return context - def do_search(self, request, queryset): - """ - This performs a full-text search on the result set - Eg: ?search=James Joyce - """ - search_enabled = getattr(settings, 'WAGTAILAPI_SEARCH_ENABLED', True) - - if 'search' in request.GET: - if not search_enabled: - raise BadRequestError("search is disabled") - - # Searching and filtering by tag at the same time is not supported - if getattr(queryset, '_filtered_by_tag', False): - raise BadRequestError("filtering by tag with a search query is not supported") - - search_query = request.GET['search'] - - sb = get_search_backend() - queryset = sb.search(search_query, queryset) - - return queryset - - def do_pagination(self, request, queryset): - """ - This performs limit/offset based pagination on the result set - Eg: ?limit=10&offset=20 -- Returns 10 items starting at item 20 - """ - limit_max = getattr(settings, 'WAGTAILAPI_LIMIT_MAX', 20) - - try: - offset = int(request.GET.get('offset', 0)) - assert offset >= 0 - except (ValueError, AssertionError): - raise BadRequestError("offset must be a positive integer") - - try: - limit = int(request.GET.get('limit', min(20, limit_max))) - - if limit > limit_max: - raise BadRequestError("limit cannot be higher than %d" % limit_max) - - assert limit >= 0 - except (ValueError, AssertionError): - raise BadRequestError("limit must be a positive integer") - - start = offset - stop = offset + limit - - return queryset[start:stop] - - def get_urlpatterns(self): + @classmethod + def get_urlpatterns(cls): """ This returns a list of URL patterns for the endpoint """ return [ - url(r'^$', self.listing_view, name='listing'), - url(r'^(\d+)/$', self.detail_view, name='detail'), + url(r'^$', cls.as_view({'get': 'listing_view'}), name='listing'), + url(r'^(?P\d+)/$', cls.as_view({'get': 'detail_view'}), name='detail'), ] - def has_model(self, model): - return False + @classmethod + def has_model(cls, model): + return NotImplemented class PagesAPIEndpoint(BaseAPIEndpoint): + serializer_class = PageSerializer + filter_backends = [ + FieldsFilter, + ChildOfFilter, + DescendantOfFilter, + OrderingFilter, + SearchFilter + ] known_query_parameters = BaseAPIEndpoint.known_query_parameters.union([ 'type', 'child_of', 'descendant_of', ]) + extra_api_fields = ['title'] + name = 'pages' + + def get_queryset(self): + request = self.request + + # Allow pages to be filtered to a specific type + if 'type' not in request.GET: + model = Page + else: + model_name = request.GET['type'] + try: + model = resolve_model_string(model_name) + except LookupError: + raise BadRequestError("type doesn't exist") + if not issubclass(model, Page): + raise BadRequestError("type doesn't exist") - def get_queryset(self, request, model=Page): # Get live pages that are not in a private section queryset = model.objects.public().live() @@ -328,245 +174,33 @@ class PagesAPIEndpoint(BaseAPIEndpoint): return queryset - def get_api_fields(self, model): - api_fields = ['title'] - api_fields.extend(super(PagesAPIEndpoint, self).get_api_fields(model)) - return api_fields + def get_object(self): + base = super(PagesAPIEndpoint, self).get_object() + return base.specific - def serialize_object_metadata(self, request, page, show_details=False): - data = super(PagesAPIEndpoint, self).serialize_object_metadata(request, page, show_details=show_details) - - # Add type - data['type'] = page.specific_class._meta.app_label + '.' + page.specific_class.__name__ - - return data - - def serialize_object(self, request, page, fields=frozenset(), extra_data=(), all_fields=False, show_details=False): - # Add parent - if show_details: - parent = page.get_parent() - - # Make sure the parent is visible in the API - if self.get_queryset(request).filter(id=parent.id).exists(): - parent_class = parent.specific_class - - extra_data += ( - ('parent', OrderedDict([ - ('id', parent.id), - ('meta', OrderedDict([ - ('type', parent_class._meta.app_label + '.' + parent_class.__name__), - ('detail_url', ObjectDetailURL(parent_class, parent.id)), - ])), - ])), - ) - - return super(PagesAPIEndpoint, self).serialize_object(request, page, fields=fields, extra_data=extra_data, all_fields=all_fields, show_details=show_details) - - def get_model(self, request): - if 'type' not in request.GET: - return Page - - model_name = request.GET['type'] - try: - model = resolve_model_string(model_name) - - if not issubclass(model, Page): - raise BadRequestError("type doesn't exist") - - return model - except LookupError: - raise BadRequestError("type doesn't exist") - - def do_child_of_filter(self, request, queryset): - if 'child_of' in request.GET: - try: - parent_page_id = int(request.GET['child_of']) - assert parent_page_id >= 0 - except (ValueError, AssertionError): - raise BadRequestError("child_of must be a positive integer") - - try: - parent_page = self.get_queryset(request).get(id=parent_page_id) - queryset = queryset.child_of(parent_page) - queryset._filtered_by_child_of = True - return queryset - except Page.DoesNotExist: - raise BadRequestError("parent page doesn't exist") - - return queryset - - def do_descendant_of_filter(self, request, queryset): - if 'descendant_of' in request.GET: - if getattr(queryset, '_filtered_by_child_of', False): - raise BadRequestError("filtering by descendant_of with child_of is not supported") - try: - ancestor_page_id = int(request.GET['descendant_of']) - assert ancestor_page_id >= 0 - except (ValueError, AssertionError): - raise BadRequestError("descendant_of must be a positive integer") - - try: - ancestor_page = self.get_queryset(request).get(id=ancestor_page_id) - return queryset.descendant_of(ancestor_page) - except Page.DoesNotExist: - raise BadRequestError("ancestor page doesn't exist") - - return queryset - - def listing_view(self, request): - # Get model and queryset - model = self.get_model(request) - queryset = self.get_queryset(request, model=model) - - # Check query paramters - self.check_query_parameters(request, queryset) - - # Filtering - queryset = self.do_field_filtering(request, queryset) - queryset = self.do_child_of_filter(request, queryset) - queryset = self.do_descendant_of_filter(request, queryset) - - # Ordering - queryset = self.do_ordering(request, queryset) - - # Search - queryset = self.do_search(request, queryset) - - # Pagination - total_count = queryset.count() - queryset = self.do_pagination(request, queryset) - - # Get list of fields to show in results - if 'fields' in request.GET: - fields = set(request.GET['fields'].split(',')) - else: - fields = {'title'} - - return OrderedDict([ - ('meta', OrderedDict([ - ('total_count', total_count), - ])), - ('pages', [ - self.serialize_object(request, page, fields=fields) - for page in queryset - ]), - ]) - - def detail_view(self, request, pk): - page = get_object_or_404(self.get_queryset(request), pk=pk).specific - return self.serialize_object(request, page, all_fields=True, show_details=True) - - def has_model(self, model): + @classmethod + def has_model(cls, model): return issubclass(model, Page) class ImagesAPIEndpoint(BaseAPIEndpoint): - model = get_image_model() + queryset = get_image_model().objects.all().order_by('id') + filter_backends = [FieldsFilter, OrderingFilter, SearchFilter] + extra_api_fields = ['title', 'tags', 'width', 'height'] + name = 'images' - def get_queryset(self, request): - return self.model.objects.all().order_by('id') - - def get_api_fields(self, model): - api_fields = ['title', 'tags', 'width', 'height'] - api_fields.extend(super(ImagesAPIEndpoint, self).get_api_fields(model)) - return api_fields - - def listing_view(self, request): - queryset = self.get_queryset(request) - - # Check query paramters - self.check_query_parameters(request, queryset) - - # Filtering - queryset = self.do_field_filtering(request, queryset) - - # Ordering - queryset = self.do_ordering(request, queryset) - - # Search - queryset = self.do_search(request, queryset) - - # Pagination - total_count = queryset.count() - queryset = self.do_pagination(request, queryset) - - # Get list of fields to show in results - if 'fields' in request.GET: - fields = set(request.GET['fields'].split(',')) - else: - fields = {'title'} - - return OrderedDict([ - ('meta', OrderedDict([ - ('total_count', total_count), - ])), - ('images', [ - self.serialize_object(request, image, fields=fields) - for image in queryset - ]), - ]) - - def detail_view(self, request, pk): - image = get_object_or_404(self.get_queryset(request), pk=pk) - return self.serialize_object(request, image, all_fields=True) - - def has_model(self, model): - return model == self.model + @classmethod + def has_model(cls, model): + return model == get_image_model() class DocumentsAPIEndpoint(BaseAPIEndpoint): - def get_api_fields(self, model): - api_fields = ['title', 'tags'] - api_fields.extend(super(DocumentsAPIEndpoint, self).get_api_fields(model)) - return api_fields + queryset = Document.objects.all().order_by('id') + serializer_class = DocumentSerializer + filter_backends = [FieldsFilter, OrderingFilter, SearchFilter] + extra_api_fields = ['title', 'tags'] + name = 'documents' - def serialize_object_metadata(self, request, document, show_details=False): - data = super(DocumentsAPIEndpoint, self).serialize_object_metadata(request, document, show_details=show_details) - - # Download URL - if show_details: - data['download_url'] = URLPath(document.url) - - return data - - def listing_view(self, request): - queryset = Document.objects.all().order_by('id') - - # Check query paramters - self.check_query_parameters(request, queryset) - - # Filtering - queryset = self.do_field_filtering(request, queryset) - - # Ordering - queryset = self.do_ordering(request, queryset) - - # Search - queryset = self.do_search(request, queryset) - - # Pagination - total_count = queryset.count() - queryset = self.do_pagination(request, queryset) - - # Get list of fields to show in results - if 'fields' in request.GET: - fields = set(request.GET['fields'].split(',')) - else: - fields = {'title'} - - return OrderedDict([ - ('meta', OrderedDict([ - ('total_count', total_count), - ])), - ('documents', [ - self.serialize_object(request, document, fields=fields) - for document in queryset - ]), - ]) - - def detail_view(self, request, pk): - document = get_object_or_404(Document, pk=pk) - return self.serialize_object(request, document, all_fields=True, show_details=True) - - def has_model(self, model): + @classmethod + def has_model(cls, model): return model == Document diff --git a/wagtail/contrib/wagtailapi/filters.py b/wagtail/contrib/wagtailapi/filters.py new file mode 100644 index 000000000..780806573 --- /dev/null +++ b/wagtail/contrib/wagtailapi/filters.py @@ -0,0 +1,150 @@ +from django.conf import settings + +from rest_framework.filters import BaseFilterBackend + +from taggit.managers import _TaggableManager + +from wagtail.wagtailcore.models import Page +from wagtail.wagtailsearch.backends import get_search_backend + +from .utils import BadRequestError, pages_for_site + + +class FieldsFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + """ + This performs field level filtering on the result set + Eg: ?title=James Joyce + """ + fields = set(view.get_api_fields(queryset.model)).union({'id'}) + + for field_name, value in request.GET.items(): + if field_name in fields: + field = getattr(queryset.model, field_name, None) + + if isinstance(field, _TaggableManager): + for tag in value.split(','): + queryset = queryset.filter(**{field_name + '__name': tag}) + + # Stick a message on the queryset to indicate that tag filtering has been performed + # This will let the do_search method know that it must raise an error as searching + # and tag filtering at the same time is not supported + queryset._filtered_by_tag = True + else: + queryset = queryset.filter(**{field_name: value}) + + return queryset + + +class OrderingFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + """ + This applies ordering to the result set + Eg: ?order=title + + It also supports reverse ordering + Eg: ?order=-title + + And random ordering + Eg: ?order=random + """ + if 'order' in request.GET: + # Prevent ordering while searching + if 'search' in request.GET: + raise BadRequestError("ordering with a search query is not supported") + + order_by = request.GET['order'] + + # Random ordering + if order_by == 'random': + # Prevent ordering by random with offset + if 'offset' in request.GET: + raise BadRequestError("random ordering with offset is not supported") + + return queryset.order_by('?') + + # Check if reverse ordering is set + if order_by.startswith('-'): + reverse_order = True + order_by = order_by[1:] + else: + reverse_order = False + + # Add ordering + if order_by == 'id' or order_by in view.get_api_fields(queryset.model): + queryset = queryset.order_by(order_by) + else: + # Unknown field + raise BadRequestError("cannot order by '%s' (unknown field)" % order_by) + + # Reverse order + if reverse_order: + queryset = queryset.reverse() + + return queryset + + +class SearchFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + """ + This performs a full-text search on the result set + Eg: ?search=James Joyce + """ + search_enabled = getattr(settings, 'WAGTAILAPI_SEARCH_ENABLED', True) + + if 'search' in request.GET: + if not search_enabled: + raise BadRequestError("search is disabled") + + # Searching and filtering by tag at the same time is not supported + if getattr(queryset, '_filtered_by_tag', False): + raise BadRequestError("filtering by tag with a search query is not supported") + + search_query = request.GET['search'] + + sb = get_search_backend() + queryset = sb.search(search_query, queryset) + + return queryset + + +class ChildOfFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + if 'child_of' in request.GET: + try: + parent_page_id = int(request.GET['child_of']) + assert parent_page_id >= 0 + except (ValueError, AssertionError): + raise BadRequestError("child_of must be a positive integer") + + site_pages = pages_for_site(request.site) + try: + parent_page = site_pages.get(id=parent_page_id) + queryset = queryset.child_of(parent_page) + queryset._filtered_by_child_of = True + return queryset + except Page.DoesNotExist: + raise BadRequestError("parent page doesn't exist") + + return queryset + + +class DescendantOfFilter(BaseFilterBackend): + def filter_queryset(self, request, queryset, view): + if 'descendant_of' in request.GET: + if getattr(queryset, '_filtered_by_child_of', False): + raise BadRequestError("filtering by descendant_of with child_of is not supported") + try: + ancestor_page_id = int(request.GET['descendant_of']) + assert ancestor_page_id >= 0 + except (ValueError, AssertionError): + raise BadRequestError("descendant_of must be a positive integer") + + site_pages = pages_for_site(request.site) + try: + ancestor_page = site_pages.get(id=ancestor_page_id) + return queryset.descendant_of(ancestor_page) + except Page.DoesNotExist: + raise BadRequestError("ancestor page doesn't exist") + + return queryset diff --git a/wagtail/contrib/wagtailapi/pagination.py b/wagtail/contrib/wagtailapi/pagination.py new file mode 100644 index 000000000..6cb470e06 --- /dev/null +++ b/wagtail/contrib/wagtailapi/pagination.py @@ -0,0 +1,45 @@ +from collections import OrderedDict + +from django.conf import settings + +from rest_framework.pagination import BasePagination +from rest_framework.response import Response + +from .utils import BadRequestError + + +class WagtailPagination(BasePagination): + def paginate_queryset(self, queryset, request, view=None): + limit_max = getattr(settings, 'WAGTAILAPI_LIMIT_MAX', 20) + + try: + offset = int(request.GET.get('offset', 0)) + assert offset >= 0 + except (ValueError, AssertionError): + raise BadRequestError("offset must be a positive integer") + + try: + limit = int(request.GET.get('limit', min(20, limit_max))) + + if limit > limit_max: + raise BadRequestError("limit cannot be higher than %d" % limit_max) + + assert limit >= 0 + except (ValueError, AssertionError): + raise BadRequestError("limit must be a positive integer") + + start = offset + stop = offset + limit + + self.view = view + self.total_count = queryset.count() + return queryset[start:stop] + + def get_paginated_response(self, data): + data = OrderedDict([ + ('meta', OrderedDict([ + ('total_count', self.total_count), + ])), + (self.view.name, data), + ]) + return Response(data) diff --git a/wagtail/contrib/wagtailapi/renderers.py b/wagtail/contrib/wagtailapi/renderers.py new file mode 100644 index 000000000..d7f9a5e90 --- /dev/null +++ b/wagtail/contrib/wagtailapi/renderers.py @@ -0,0 +1,61 @@ +import json + +from django.core.serializers.json import DjangoJSONEncoder +from django.core.urlresolvers import reverse +from django.utils.six import text_type + +from rest_framework import renderers + +from taggit.managers import _TaggableManager +from taggit.models import Tag + +from wagtail.wagtailcore.blocks import StreamValue + +from .utils import URLPath, ObjectDetailURL, get_base_url + + +def get_full_url(request, path): + base_url = get_base_url(request) or '' + return base_url + path + + +def find_model_detail_view(model, endpoints): + for endpoint in endpoints: + if endpoint.has_model(model): + return 'wagtailapi_v1:%s:detail' % endpoint.name + + +class WagtailJSONRenderer(renderers.BaseRenderer): + media_type = 'application/json' + charset = None + + def render(self, data, media_type=None, renderer_context=None): + request = renderer_context['request'] + endpoints = renderer_context['endpoints'] + + class WagtailAPIJSONEncoder(DjangoJSONEncoder): + def default(self, o): + if isinstance(o, _TaggableManager): + return list(o.all()) + elif isinstance(o, Tag): + return o.name + elif isinstance(o, URLPath): + return get_full_url(request, o.path) + elif isinstance(o, ObjectDetailURL): + detail_view = find_model_detail_view(o.model, endpoints) + + if detail_view: + return get_full_url(request, reverse(detail_view, args=(o.pk, ))) + else: + return None + elif isinstance(o, StreamValue): + return o.stream_block.get_prep_value(o) + else: + return super(WagtailAPIJSONEncoder, self).default(o) + + ret = json.dumps(data, indent=4, cls=WagtailAPIJSONEncoder) + + # Deal with inconsistent py2/py3 behavior, and always return bytes. + if isinstance(ret, text_type): + return bytes(ret.encode('utf-8')) + return ret diff --git a/wagtail/contrib/wagtailapi/serializers.py b/wagtail/contrib/wagtailapi/serializers.py new file mode 100644 index 000000000..a28f6f2f5 --- /dev/null +++ b/wagtail/contrib/wagtailapi/serializers.py @@ -0,0 +1,172 @@ +from __future__ import absolute_import + +from collections import OrderedDict + +from django.db import models +from django.utils.encoding import force_text + +from modelcluster.models import get_all_child_relations + +from rest_framework.serializers import BaseSerializer + +from wagtail.utils.compat import get_related_model +from wagtail.wagtailcore.models import Page + +from .utils import ObjectDetailURL, URLPath, BadRequestError, pages_for_site + + +def get_api_data(obj, fields): + # Find any child relations (pages only) + child_relations = {} + if isinstance(obj, Page): + child_relations = { + child_relation.field.rel.related_name: get_related_model(child_relation) + for child_relation in get_all_child_relations(type(obj)) + } + + # Loop through fields + for field_name in fields: + # Check child relations + if field_name in child_relations and hasattr(child_relations[field_name], 'api_fields'): + yield field_name, [ + dict(get_api_data(child_object, child_relations[field_name].api_fields)) + for child_object in getattr(obj, field_name).all() + ] + continue + + # Check django fields + try: + field = obj._meta.get_field(field_name) + + if field.rel and isinstance(field.rel, models.ManyToOneRel): + # Foreign key + val = field._get_val_from_obj(obj) + + if val: + yield field_name, OrderedDict([ + ('id', field._get_val_from_obj(obj)), + ('meta', OrderedDict([ + ('type', field.rel.to._meta.app_label + '.' + field.rel.to.__name__), + ('detail_url', ObjectDetailURL(field.rel.to, val)), + ])), + ]) + else: + yield field_name, None + else: + yield field_name, field._get_val_from_obj(obj) + + continue + except models.fields.FieldDoesNotExist: + pass + + # Check attributes + if hasattr(obj, field_name): + value = getattr(obj, field_name) + yield field_name, force_text(value, strings_only=True) + continue + + +class WagtailSerializer(BaseSerializer): + def to_representation(self, instance): + request = self.context['request'] + fields = self.context.get('fields', frozenset()) + all_fields = self.context.get('all_fields', False) + show_details = self.context.get('show_details', False) + return self.serialize_object( + request, + instance, + fields=fields, + all_fields=all_fields, + show_details=show_details + ) + + def serialize_object_metadata(self, request, obj, show_details=False): + """ + This returns a JSON-serialisable dict to use for the "meta" + section of a particlular object. + """ + data = OrderedDict() + + # Add type + data['type'] = type(obj)._meta.app_label + '.' + type(obj).__name__ + data['detail_url'] = ObjectDetailURL(type(obj), obj.pk) + + return data + + def serialize_object(self, request, obj, fields=frozenset(), extra_data=(), all_fields=False, show_details=False): + """ + This converts an object into JSON-serialisable dict so it can + be used in the API. + """ + data = [ + ('id', obj.id), + ] + + # Add meta + metadata = self.serialize_object_metadata(request, obj, show_details=show_details) + if metadata: + data.append(('meta', metadata)) + + # Add extra data + data.extend(extra_data) + + # Add other fields + api_fields = self.context['view'].get_api_fields(type(obj)) + api_fields = list(OrderedDict.fromkeys(api_fields)) # Removes any duplicates in case the user put "title" in api_fields + + if all_fields: + fields = api_fields + else: + unknown_fields = fields - set(api_fields) + + if unknown_fields: + raise BadRequestError("unknown fields: %s" % ', '.join(sorted(unknown_fields))) + + # Reorder fields so it matches the order of api_fields + fields = [field for field in api_fields if field in fields] + + data.extend(get_api_data(obj, fields)) + + return OrderedDict(data) + + +class PageSerializer(WagtailSerializer): + def serialize_object_metadata(self, request, page, show_details=False): + data = super(PageSerializer, self).serialize_object_metadata(request, page, show_details=show_details) + + # Add type + data['type'] = page.specific_class._meta.app_label + '.' + page.specific_class.__name__ + + return data + + def serialize_object(self, request, page, fields=frozenset(), extra_data=(), all_fields=False, show_details=False): + # Add parent + if show_details: + parent = page.get_parent() + + site_pages = pages_for_site(request.site) + if site_pages.filter(id=parent.id).exists(): + parent_class = parent.specific_class + + extra_data += ( + ('parent', OrderedDict([ + ('id', parent.id), + ('meta', OrderedDict([ + ('type', parent_class._meta.app_label + '.' + parent_class.__name__), + ('detail_url', ObjectDetailURL(parent_class, parent.id)), + ])), + ])), + ) + + return super(PageSerializer, self).serialize_object(request, page, fields=fields, extra_data=extra_data, all_fields=all_fields, show_details=show_details) + + +class DocumentSerializer(WagtailSerializer): + def serialize_object_metadata(self, request, document, show_details=False): + data = super(DocumentSerializer, self).serialize_object_metadata(request, document, show_details=show_details) + + # Download URL + if show_details: + data['download_url'] = URLPath(document.url) + + return data diff --git a/wagtail/contrib/wagtailapi/urls.py b/wagtail/contrib/wagtailapi/urls.py index 1aa914e36..492772f28 100644 --- a/wagtail/contrib/wagtailapi/urls.py +++ b/wagtail/contrib/wagtailapi/urls.py @@ -2,9 +2,16 @@ from __future__ import absolute_import from django.conf.urls import url, include -from . import api +from .endpoints import PagesAPIEndpoint, ImagesAPIEndpoint, DocumentsAPIEndpoint + + +v1 = [ + url(r'^pages/', include(PagesAPIEndpoint.get_urlpatterns(), namespace='pages')), + url(r'^images/', include(ImagesAPIEndpoint.get_urlpatterns(), namespace='images')), + url(r'^documents/', include(DocumentsAPIEndpoint.get_urlpatterns(), namespace='documents')) +] urlpatterns = [ - url(r'^v1/', include(api.v1.get_urlpatterns(), namespace='wagtailapi_v1')), + url(r'^v1/', include(v1, namespace='wagtailapi_v1')), ] diff --git a/wagtail/contrib/wagtailapi/utils.py b/wagtail/contrib/wagtailapi/utils.py index 11af445d2..03c38ee16 100644 --- a/wagtail/contrib/wagtailapi/utils.py +++ b/wagtail/contrib/wagtailapi/utils.py @@ -1,11 +1,35 @@ from django.conf import settings from django.utils.six.moves.urllib.parse import urlparse +from wagtail.wagtailcore.models import Page + class BadRequestError(Exception): pass +class URLPath(object): + """ + This class represents a URL path that should be converted to a full URL. + + It is used when the domain that should be used is not known at the time + the URL was generated. It will get resolved to a full URL during + serialisation in api.py. + + One example use case is the documents endpoint adding download URLs into + the JSON. The endpoint does not know the domain name to use at the time so + returns one of these instead. + """ + def __init__(self, path): + self.path = path + + +class ObjectDetailURL(object): + def __init__(self, model, pk): + self.model = model + self.pk = pk + + def get_base_url(request=None): base_url = getattr(settings, 'WAGTAILAPI_BASE_URL', request.site.root_url if request else None) @@ -14,3 +38,9 @@ def get_base_url(request=None): base_url_parsed = urlparse(base_url) return base_url_parsed.scheme + '://' + base_url_parsed.netloc + + +def pages_for_site(site): + pages = Page.objects.public().live() + pages = pages.descendant_of(site.root_page, inclusive=True) + return pages diff --git a/wagtail/contrib/wagtailsearchpromotions/__init__.py b/wagtail/contrib/wagtailsearchpromotions/__init__.py new file mode 100644 index 000000000..e1fe20c62 --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/__init__.py @@ -0,0 +1 @@ +default_app_config = 'wagtail.contrib.wagtailsearchpromotions.apps.WagtailSearchPromotionsAppConfig' diff --git a/wagtail/contrib/wagtailsearchpromotions/admin_urls.py b/wagtail/contrib/wagtailsearchpromotions/admin_urls.py new file mode 100644 index 000000000..6b1a42fc5 --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/admin_urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url +from wagtail.contrib.wagtailsearchpromotions import views + + +urlpatterns = [ + url(r'^$', views.index, name='index'), + url(r'^add/$', views.add, name='add'), + url(r'^(\d+)/$', views.edit, name='edit'), + url(r'^(\d+)/delete/$', views.delete, name='delete'), +] diff --git a/wagtail/contrib/wagtailsearchpromotions/apps.py b/wagtail/contrib/wagtailsearchpromotions/apps.py new file mode 100644 index 000000000..586ba34b8 --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WagtailSearchPromotionsAppConfig(AppConfig): + name = 'wagtail.contrib.wagtailsearchpromotions' + label = 'wagtailsearchpromotions' + verbose_name = "Wagtail search promotions" diff --git a/wagtail/contrib/wagtailsearchpromotions/forms.py b/wagtail/contrib/wagtailsearchpromotions/forms.py new file mode 100644 index 000000000..b54b08874 --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/forms.py @@ -0,0 +1,58 @@ +from django import forms +from django.forms.models import inlineformset_factory +from django.utils.translation import ugettext_lazy as _ + +from wagtail.wagtailadmin.widgets import AdminPageChooser +from wagtail.wagtailsearch.models import Query +from wagtail.contrib.wagtailsearchpromotions.models import SearchPromotion + + +class SearchPromotionForm(forms.ModelForm): + sort_order = forms.IntegerField(required=False) + + def __init__(self, *args, **kwargs): + super(SearchPromotionForm, self).__init__(*args, **kwargs) + self.fields['page'].widget = AdminPageChooser() + + class Meta: + model = SearchPromotion + fields = ('query', 'page', 'description') + + widgets = { + 'description': forms.Textarea(attrs=dict(rows=3)), + } + + +SearchPromotionsFormSetBase = inlineformset_factory(Query, SearchPromotion, form=SearchPromotionForm, can_order=True, can_delete=True, extra=0) + + +class SearchPromotionsFormSet(SearchPromotionsFormSetBase): + minimum_forms = 1 + minimum_forms_message = _("Please specify at least one recommendation for this search term.") + + def add_fields(self, form, *args, **kwargs): + super(SearchPromotionsFormSet, self).add_fields(form, *args, **kwargs) + + # Hide delete and order fields + form.fields['DELETE'].widget = forms.HiddenInput() + form.fields['ORDER'].widget = forms.HiddenInput() + + # Remove query field + del form.fields['query'] + + def clean(self): + # Search pick must have at least one recommended page to be valid + # Check there is at least one non-deleted form. + non_deleted_forms = self.total_form_count() + non_empty_forms = 0 + for i in range(0, self.total_form_count()): + form = self.forms[i] + if self.can_delete and self._should_delete_form(form): + non_deleted_forms -= 1 + if not (form.instance.id is None and not form.has_changed()): + non_empty_forms += 1 + if ( + non_deleted_forms < self.minimum_forms + or non_empty_forms < self.minimum_forms + ): + raise forms.ValidationError(self.minimum_forms_message) diff --git a/wagtail/contrib/wagtailsearchpromotions/migrations/0001_initial.py b/wagtail/contrib/wagtailsearchpromotions/migrations/0001_initial.py new file mode 100644 index 000000000..62809da8d --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailcore', '0015_add_more_verbose_names'), + ('wagtailsearch', '0003_remove_editors_pick'), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='EditorsPick', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False, verbose_name='ID', auto_created=True)), + ('sort_order', models.IntegerField(editable=False, null=True, blank=True)), + ('description', models.TextField(verbose_name='Description', blank=True)), + ('page', models.ForeignKey(verbose_name='Page', to='wagtailcore.Page')), + ('query', models.ForeignKey(to='wagtailsearch.Query', related_name='editors_picks')), + ], + options={ + 'db_table': 'wagtailsearch_editorspick', + 'verbose_name': "Editor's Pick", + 'ordering': ('sort_order',), + }, + ), + ], + database_operations=[] + ), + migrations.AlterModelTable( + name='editorspick', + table=None, + ), + migrations.RenameModel( + old_name='EditorsPick', + new_name='SearchPromotion' + ), + migrations.AlterModelOptions( + name='searchpromotion', + options={'ordering': ('sort_order',), 'verbose_name': 'Search promotion'}, + ), + ] diff --git a/wagtail/contrib/wagtailsearchpromotions/migrations/__init__.py b/wagtail/contrib/wagtailsearchpromotions/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/wagtailsearchpromotions/models.py b/wagtail/contrib/wagtailsearchpromotions/models.py new file mode 100644 index 000000000..28f33eb01 --- /dev/null +++ b/wagtail/contrib/wagtailsearchpromotions/models.py @@ -0,0 +1,18 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from wagtail.wagtailsearch.models import Query + + +class SearchPromotion(models.Model): + query = models.ForeignKey(Query, db_index=True, related_name='editors_picks') + page = models.ForeignKey('wagtailcore.Page', verbose_name=_('Page')) + sort_order = models.IntegerField(null=True, blank=True, editable=False) + description = models.TextField(verbose_name=_('Description'), blank=True) + + def __repr__(self): + return 'SearchPromotion(query="' + self.query.query_string + '", page="' + self.page.title + '")' + + class Meta: + ordering = ('sort_order', ) + verbose_name = _("Search promotion") diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/add.html similarity index 79% rename from wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html rename to wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/add.html index 3135ce5f8..d55f313fd 100644 --- a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/add.html +++ b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/add.html @@ -1,8 +1,8 @@ {% extends "wagtailadmin/base.html" %} {% load i18n %} -{% block titletag %}{% trans "Add editor's pick" %}{% endblock %} +{% block titletag %}{% trans "Add search pick" %}{% endblock %} {% block content %} - {% trans "Add editor's pick" as add_str %} + {% trans "Add search pick" as add_str %} {% include "wagtailadmin/shared/header.html" with title=add_str icon="pick" %}
@@ -15,7 +15,7 @@

The "Search term(s)/phrase" field below must contain the full and exact search for which you wish to provide recommended results, including any misspellings/user error. To help, you can choose from search terms that have been popular with users of your site.

{% endblocktrans %}
-
+ {% csrf_token %} @@ -38,7 +38,7 @@ {% include "wagtailadmin/pages/_editor_js.html" %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_form.html b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotion_form.html similarity index 100% rename from wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_form.html rename to wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotion_form.html diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.html b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.html similarity index 75% rename from wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.html rename to wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.html index 0d4dec9aa..f612ac6ad 100644 --- a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.html +++ b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.html @@ -2,13 +2,13 @@ {{ formset.management_form }} diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.js b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js similarity index 100% rename from wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/includes/editorspicks_formset.js rename to wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/includes/searchpromotions_formset.js diff --git a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/index.html b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/index.html similarity index 70% rename from wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/index.html rename to wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/index.html index e8e72f238..757a681f0 100644 --- a/wagtail/wagtailsearch/templates/wagtailsearch/editorspicks/index.html +++ b/wagtail/contrib/wagtailsearchpromotions/templates/wagtailsearchpromotions/index.html @@ -6,7 +6,7 @@ {% block extra_js %} ') def test_editor_css_and_js_hooks_on_edit(self): - response = self.client.get(reverse('wagtailadmin_pages_edit', args=(self.homepage.id, ))) + response = self.client.get(reverse('wagtailadmin_pages:edit', args=(self.homepage.id, ))) self.assertEqual(response.status_code, 200) self.assertContains(response, '') self.assertContains(response, '') diff --git a/wagtail/wagtailadmin/urls.py b/wagtail/wagtailadmin/urls.py deleted file mode 100644 index b16d9db67..000000000 --- a/wagtail/wagtailadmin/urls.py +++ /dev/null @@ -1,127 +0,0 @@ -from django.conf.urls import url -from django.contrib.auth.decorators import permission_required -from django.contrib.auth import views as django_auth_views -from django.views.decorators.cache import cache_control - -from wagtail.wagtailadmin.forms import PasswordResetForm -from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar, page_privacy -from wagtail.wagtailcore import hooks -from wagtail.utils.urlpatterns import decorate_urlpatterns - - -urlpatterns = [ - url(r'^$', home.home, name='wagtailadmin_home'), - - url(r'^failwhale/$', home.error_test, name='wagtailadmin_error_test'), - - url(r'^explorer-nav/$', pages.explorer_nav, name='wagtailadmin_explorer_nav'), - - url(r'^pages/$', pages.index, name='wagtailadmin_explore_root'), - url(r'^pages/(\d+)/$', pages.index, name='wagtailadmin_explore'), - - url(r'^pages/new/(\w+)/(\w+)/(\d+)/$', pages.create, name='wagtailadmin_pages_create'), - url(r'^pages/new/(\w+)/(\w+)/(\d+)/preview/$', pages.preview_on_create, name='wagtailadmin_pages_preview_on_create'), - url(r'^pages/usage/(\w+)/(\w+)/$', pages.content_type_use, name='wagtailadmin_pages_type_use'), - - url(r'^pages/(\d+)/edit/$', pages.edit, name='wagtailadmin_pages_edit'), - url(r'^pages/(\d+)/edit/preview/$', pages.preview_on_edit, name='wagtailadmin_pages_preview_on_edit'), - - url(r'^pages/preview/$', pages.preview, name='wagtailadmin_pages_preview'), - url(r'^pages/preview_loading/$', pages.preview_loading, name='wagtailadmin_pages_preview_loading'), - - url(r'^pages/(\d+)/view_draft/$', pages.view_draft, name='wagtailadmin_pages_view_draft'), - url(r'^pages/(\d+)/add_subpage/$', pages.add_subpage, name='wagtailadmin_pages_add_subpage'), - url(r'^pages/(\d+)/delete/$', pages.delete, name='wagtailadmin_pages_delete'), - url(r'^pages/(\d+)/unpublish/$', pages.unpublish, name='wagtailadmin_pages_unpublish'), - - url(r'^pages/search/$', pages.search, name='wagtailadmin_pages_search'), - - url(r'^pages/(\d+)/move/$', pages.move_choose_destination, name='wagtailadmin_pages_move'), - url(r'^pages/(\d+)/move/(\d+)/$', pages.move_choose_destination, name='wagtailadmin_pages_move_choose_destination'), - url(r'^pages/(\d+)/move/(\d+)/confirm/$', pages.move_confirm, name='wagtailadmin_pages_move_confirm'), - url(r'^pages/(\d+)/set_position/$', pages.set_page_position, name='wagtailadmin_pages_set_page_position'), - - url(r'^pages/(\d+)/copy/$', pages.copy, name='wagtailadmin_pages_copy'), - - url(r'^pages/moderation/(\d+)/approve/$', pages.approve_moderation, name='wagtailadmin_pages_approve_moderation'), - 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'^pages/(\d+)/lock/$', pages.lock, name='wagtailadmin_pages_lock'), - url(r'^pages/(\d+)/unlock/$', pages.unlock, name='wagtailadmin_pages_unlock'), - - 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-page/search/$', chooser.search, name='wagtailadmin_choose_page_search'), - url(r'^choose-external-link/$', chooser.external_link, name='wagtailadmin_choose_page_external_link'), - url(r'^choose-email-link/$', chooser.email_link, name='wagtailadmin_choose_page_email_link'), - - url(r'^tag-autocomplete/$', tags.autocomplete, name='wagtailadmin_tag_autocomplete'), - - url(r'^account/$', account.account, name='wagtailadmin_account'), - url(r'^account/change_password/$', account.change_password, name='wagtailadmin_account_change_password'), - url(r'^account/notification_preferences/$', account.notification_preferences, name='wagtailadmin_account_notification_preferences'), - url(r'^logout/$', account.logout, name='wagtailadmin_logout'), -] - - -# Import additional urlpatterns from any apps that define a register_admin_urls hook -for fn in hooks.get_hooks('register_admin_urls'): - urls = fn() - if urls: - urlpatterns += urls - - -# Add "wagtailadmin.access_admin" permission check -urlpatterns = decorate_urlpatterns(urlpatterns, - permission_required( - 'wagtailadmin.access_admin', - login_url='wagtailadmin_login' - ) -) - - -# These url patterns do not require an authenticated admin user -urlpatterns += [ - url(r'^login/$', account.login, name='wagtailadmin_login'), - - # These two URLs have the "permission_required" decorator applied directly - # as they need to fail with a 403 error rather than redirect to the login page - url(r'^userbar/(\d+)/$', userbar.for_frontend, name='wagtailadmin_userbar_frontend'), - url(r'^userbar/moderation/(\d+)/$', userbar.for_moderation, name='wagtailadmin_userbar_moderation'), - - # Password reset - url( - r'^password_reset/$', django_auth_views.password_reset, { - 'template_name': 'wagtailadmin/account/password_reset/form.html', - 'email_template_name': 'wagtailadmin/account/password_reset/email.txt', - 'subject_template_name': 'wagtailadmin/account/password_reset/email_subject.txt', - 'password_reset_form': PasswordResetForm, - 'post_reset_redirect': 'wagtailadmin_password_reset_done', - }, name='wagtailadmin_password_reset' - ), - url( - r'^password_reset/done/$', django_auth_views.password_reset_done, { - 'template_name': 'wagtailadmin/account/password_reset/done.html' - }, name='wagtailadmin_password_reset_done' - ), - url( - r'^password_reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', - django_auth_views.password_reset_confirm, { - 'template_name': 'wagtailadmin/account/password_reset/confirm.html', - 'post_reset_redirect': 'wagtailadmin_password_reset_complete', - }, name='wagtailadmin_password_reset_confirm', - ), - url( - r'^password_reset/complete/$', django_auth_views.password_reset_complete, { - 'template_name': 'wagtailadmin/account/password_reset/complete.html' - }, name='wagtailadmin_password_reset_complete' - ), -] - -# Decorate all views with cache settings to prevent caching -urlpatterns = decorate_urlpatterns(urlpatterns, - cache_control(private=True, no_cache=True, no_store=True, max_age=0) -) diff --git a/wagtail/wagtailadmin/urls/__init__.py b/wagtail/wagtailadmin/urls/__init__.py new file mode 100644 index 000000000..01fea763b --- /dev/null +++ b/wagtail/wagtailadmin/urls/__init__.py @@ -0,0 +1,73 @@ +from django.conf.urls import url, include +from django.contrib.auth.decorators import permission_required +from django.views.decorators.cache import cache_control + +from wagtail.wagtailadmin.urls import pages as wagtailadmin_pages_urls +from wagtail.wagtailadmin.urls import password_reset as wagtailadmin_password_reset_urls +from wagtail.wagtailadmin.views import account, chooser, home, pages, tags, userbar +from wagtail.wagtailcore import hooks +from wagtail.utils.urlpatterns import decorate_urlpatterns + + +urlpatterns = [ + url(r'^$', home.home, name='wagtailadmin_home'), + + url(r'^failwhale/$', home.error_test, name='wagtailadmin_error_test'), + + url(r'^explorer-nav/$', pages.explorer_nav, name='wagtailadmin_explorer_nav'), + + # TODO: Move into wagtailadmin_pages namespace + url(r'^pages/$', pages.index, name='wagtailadmin_explore_root'), + url(r'^pages/(\d+)/$', pages.index, name='wagtailadmin_explore'), + + url(r'^pages/', include(wagtailadmin_pages_urls, namespace='wagtailadmin_pages')), + + # TODO: Move into wagtailadmin_pages namespace + 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-page/search/$', chooser.search, name='wagtailadmin_choose_page_search'), + url(r'^choose-external-link/$', chooser.external_link, name='wagtailadmin_choose_page_external_link'), + url(r'^choose-email-link/$', chooser.email_link, name='wagtailadmin_choose_page_email_link'), + + url(r'^tag-autocomplete/$', tags.autocomplete, name='wagtailadmin_tag_autocomplete'), + + url(r'^account/$', account.account, name='wagtailadmin_account'), + url(r'^account/change_password/$', account.change_password, name='wagtailadmin_account_change_password'), + url(r'^account/notification_preferences/$', account.notification_preferences, name='wagtailadmin_account_notification_preferences'), + url(r'^logout/$', account.logout, name='wagtailadmin_logout'), +] + + +# Import additional urlpatterns from any apps that define a register_admin_urls hook +for fn in hooks.get_hooks('register_admin_urls'): + urls = fn() + if urls: + urlpatterns += urls + + +# Add "wagtailadmin.access_admin" permission check +urlpatterns = decorate_urlpatterns(urlpatterns, + permission_required( + 'wagtailadmin.access_admin', + login_url='wagtailadmin_login' + ) +) + + +# These url patterns do not require an authenticated admin user +urlpatterns += [ + url(r'^login/$', account.login, name='wagtailadmin_login'), + + # These two URLs have the "permission_required" decorator applied directly + # as they need to fail with a 403 error rather than redirect to the login page + url(r'^userbar/(\d+)/$', userbar.for_frontend, name='wagtailadmin_userbar_frontend'), + url(r'^userbar/moderation/(\d+)/$', userbar.for_moderation, name='wagtailadmin_userbar_moderation'), + + # Password reset + url(r'^password_reset/', include(wagtailadmin_password_reset_urls)), +] + +# Decorate all views with cache settings to prevent caching +urlpatterns = decorate_urlpatterns(urlpatterns, + cache_control(private=True, no_cache=True, no_store=True, max_age=0) +) diff --git a/wagtail/wagtailadmin/urls/pages.py b/wagtail/wagtailadmin/urls/pages.py new file mode 100644 index 000000000..660dbaed0 --- /dev/null +++ b/wagtail/wagtailadmin/urls/pages.py @@ -0,0 +1,39 @@ +from django.conf.urls import url + +from wagtail.wagtailadmin.views import pages, page_privacy + + +urlpatterns = [ + url(r'^add/(\w+)/(\w+)/(\d+)/$', pages.create, name='add'), + url(r'^add/(\w+)/(\w+)/(\d+)/preview/$', pages.preview_on_create, name='preview_on_add'), + url(r'^usage/(\w+)/(\w+)/$', pages.content_type_use, name='type_use'), + + url(r'^(\d+)/edit/$', pages.edit, name='edit'), + url(r'^(\d+)/edit/preview/$', pages.preview_on_edit, name='preview_on_edit'), + + url(r'^preview/$', pages.preview, name='preview'), + url(r'^preview_loading/$', pages.preview_loading, name='preview_loading'), + + url(r'^(\d+)/view_draft/$', pages.view_draft, name='view_draft'), + url(r'^(\d+)/add_subpage/$', pages.add_subpage, name='add_subpage'), + url(r'^(\d+)/delete/$', pages.delete, name='delete'), + url(r'^(\d+)/unpublish/$', pages.unpublish, name='unpublish'), + + url(r'^search/$', pages.search, name='search'), + + url(r'^(\d+)/move/$', pages.move_choose_destination, name='move'), + url(r'^(\d+)/move/(\d+)/$', pages.move_choose_destination, name='move_choose_destination'), + url(r'^(\d+)/move/(\d+)/confirm/$', pages.move_confirm, name='move_confirm'), + url(r'^(\d+)/set_position/$', pages.set_page_position, name='set_page_position'), + + url(r'^(\d+)/copy/$', pages.copy, name='copy'), + + url(r'^moderation/(\d+)/approve/$', pages.approve_moderation, name='approve_moderation'), + url(r'^moderation/(\d+)/reject/$', pages.reject_moderation, name='reject_moderation'), + url(r'^moderation/(\d+)/preview/$', pages.preview_for_moderation, name='preview_for_moderation'), + + url(r'^(\d+)/privacy/$', page_privacy.set_privacy, name='set_privacy'), + + url(r'^(\d+)/lock/$', pages.lock, name='lock'), + url(r'^(\d+)/unlock/$', pages.unlock, name='unlock'), +] diff --git a/wagtail/wagtailadmin/urls/password_reset.py b/wagtail/wagtailadmin/urls/password_reset.py new file mode 100644 index 000000000..5ae9e55dc --- /dev/null +++ b/wagtail/wagtailadmin/urls/password_reset.py @@ -0,0 +1,34 @@ +from django.conf.urls import url +from django.contrib.auth import views as django_auth_views + +from wagtail.wagtailadmin.forms import PasswordResetForm + + +urlpatterns = [ + url( + r'^$', django_auth_views.password_reset, { + 'template_name': 'wagtailadmin/account/password_reset/form.html', + 'email_template_name': 'wagtailadmin/account/password_reset/email.txt', + 'subject_template_name': 'wagtailadmin/account/password_reset/email_subject.txt', + 'password_reset_form': PasswordResetForm, + 'post_reset_redirect': 'wagtailadmin_password_reset_done', + }, name='wagtailadmin_password_reset' + ), + url( + r'^done/$', django_auth_views.password_reset_done, { + 'template_name': 'wagtailadmin/account/password_reset/done.html' + }, name='wagtailadmin_password_reset_done' + ), + url( + r'^confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + django_auth_views.password_reset_confirm, { + 'template_name': 'wagtailadmin/account/password_reset/confirm.html', + 'post_reset_redirect': 'wagtailadmin_password_reset_complete', + }, name='wagtailadmin_password_reset_confirm', + ), + url( + r'^complete/$', django_auth_views.password_reset_complete, { + 'template_name': 'wagtailadmin/account/password_reset/complete.html' + }, name='wagtailadmin_password_reset_complete' + ), +] diff --git a/wagtail/wagtailadmin/views/chooser.py b/wagtail/wagtailadmin/views/chooser.py index 4ece62c00..bb45af3b5 100644 --- a/wagtail/wagtailadmin/views/chooser.py +++ b/wagtail/wagtailadmin/views/chooser.py @@ -20,6 +20,16 @@ def get_querystring(request): }) +def shared_context(request, extra_context={}): + context = { + 'allow_external_link': request.GET.get('allow_external_link'), + 'allow_email_link': request.GET.get('allow_email_link'), + 'querystring': get_querystring(request), + } + context.update(extra_context) + return context + + def browse(request, parent_page_id=None): ITEMS_PER_PAGE = 25 @@ -82,17 +92,18 @@ def browse(request, parent_page_id=None): except EmptyPage: pages = paginator.page(paginator.num_pages) - return render_modal_workflow(request, 'wagtailadmin/chooser/browse.html', 'wagtailadmin/chooser/browse.js', { - 'allow_external_link': request.GET.get('allow_external_link'), - 'allow_email_link': request.GET.get('allow_email_link'), - 'querystring': get_querystring(request), - 'parent_page': parent_page, - 'pages': pages, - 'search_form': search_form, - 'page_type_string': ','.join(page_types), - 'page_type_names': [desired_class.get_verbose_name() for desired_class in desired_classes], - 'page_types_restricted': (page_type != 'wagtailcore.page') - }) + return render_modal_workflow( + request, + 'wagtailadmin/chooser/browse.html', 'wagtailadmin/chooser/browse.js', + shared_context(request, { + 'parent_page': parent_page, + 'pages': pages, + 'search_form': search_form, + 'page_type_string': ','.join(page_types), + 'page_type_names': [desired_class.get_verbose_name() for desired_class in desired_classes], + 'page_types_restricted': (page_type != 'wagtailcore.page') + }) + ) def search(request, parent_page_id=None): @@ -130,11 +141,13 @@ def search(request, parent_page_id=None): page.can_choose = True shown_pages.append(page) - return render(request, 'wagtailadmin/chooser/_search_results.html', { - 'querystring': get_querystring(request), - 'searchform': search_form, - 'pages': shown_pages, - }) + return render( + request, 'wagtailadmin/chooser/_search_results.html', + shared_context(request, { + 'searchform': search_form, + 'pages': shown_pages, + }) + ) def external_link(request): @@ -162,11 +175,9 @@ def external_link(request): return render_modal_workflow( request, 'wagtailadmin/chooser/external_link.html', 'wagtailadmin/chooser/external_link.js', - { - 'querystring': get_querystring(request), - 'allow_email_link': request.GET.get('allow_email_link'), + shared_context(request, { 'form': form, - } + }) ) @@ -195,9 +206,7 @@ def email_link(request): return render_modal_workflow( request, 'wagtailadmin/chooser/email_link.html', 'wagtailadmin/chooser/email_link.js', - { - 'querystring': get_querystring(request), - 'allow_external_link': request.GET.get('allow_external_link'), + shared_context(request, { 'form': form, - } + }) ) diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index 04720618d..f774b9e5e 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -79,7 +79,7 @@ def add_subpage(request, parent_page_id): # Only one page type is available - redirect straight to the create form rather than # making the user choose content_type = page_types[0] - return redirect('wagtailadmin_pages_create', content_type.app_label, content_type.model, parent_page.id) + return redirect('wagtailadmin_pages:add', content_type.app_label, content_type.model, parent_page.id) return render(request, 'wagtailadmin/pages/add_subpage.html', { 'parent_page': parent_page, @@ -176,12 +176,12 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ if is_publishing: messages.success(request, _("Page '{0}' created and published.").format(page.title), buttons=[ messages.button(page.url, _('View live')), - messages.button(reverse('wagtailadmin_pages_edit', args=(page.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(page.id,)), _('Edit')) ]) elif is_submitting: messages.success(request, _("Page '{0}' created and submitted for moderation.").format(page.title), buttons=[ - messages.button(reverse('wagtailadmin_pages_view_draft', args=(page.id,)), _('View draft')), - messages.button(reverse('wagtailadmin_pages_edit', args=(page.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:view_draft', args=(page.id,)), _('View draft')), + messages.button(reverse('wagtailadmin_pages:edit', args=(page.id,)), _('Edit')) ]) send_notification(page.get_latest_revision().id, 'submitted', request.user.id) else: @@ -197,7 +197,7 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ return redirect('wagtailadmin_explore', page.get_parent().id) else: # Just saving - remain on edit page for further edits - return redirect('wagtailadmin_pages_edit', page.id) + return redirect('wagtailadmin_pages:edit', page.id) else: messages.error(request, _("The page could not be created due to validation errors")) edit_handler = edit_handler_class(instance=page, form=form) @@ -256,12 +256,12 @@ def edit(request, page_id): if is_publishing: messages.success(request, _("Page '{0}' published.").format(page.title), buttons=[ messages.button(page.url, _('View live')), - messages.button(reverse('wagtailadmin_pages_edit', args=(page_id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(page_id,)), _('Edit')) ]) elif is_submitting: messages.success(request, _("Page '{0}' submitted for moderation.").format(page.title), buttons=[ - messages.button(reverse('wagtailadmin_pages_view_draft', args=(page_id,)), _('View draft')), - messages.button(reverse('wagtailadmin_pages_edit', args=(page_id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:view_draft', args=(page_id,)), _('View draft')), + messages.button(reverse('wagtailadmin_pages:edit', args=(page_id,)), _('Edit')) ]) send_notification(page.get_latest_revision().id, 'submitted', request.user.id) else: @@ -277,7 +277,7 @@ def edit(request, page_id): return redirect('wagtailadmin_explore', page.get_parent().id) else: # Just saving - remain on edit page for further edits - return redirect('wagtailadmin_pages_edit', page.id) + return redirect('wagtailadmin_pages:edit', page.id) else: if page.locked: messages.error(request, _("The page could not be saved as it is locked")) @@ -494,7 +494,7 @@ def unpublish(request, page_id): page.unpublish() messages.success(request, _("Page '{0}' unpublished.").format(page.title), buttons=[ - messages.button(reverse('wagtailadmin_pages_edit', args=(page.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(page.id,)), _('Edit')) ]) return redirect('wagtailadmin_explore', page.get_parent().id) @@ -546,7 +546,7 @@ def move_confirm(request, page_to_move_id, destination_id): page_to_move.move(destination, pos='last-child') messages.success(request, _("Page '{0}' moved.").format(page_to_move.title), buttons=[ - messages.button(reverse('wagtailadmin_pages_edit', args=(page_to_move.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(page_to_move.id,)), _('Edit')) ]) return redirect('wagtailadmin_explore', destination.id) @@ -733,7 +733,7 @@ def approve_moderation(request, revision_id): revision.approve_moderation() messages.success(request, _("Page '{0}' published.").format(revision.page.title), buttons=[ messages.button(revision.page.url, _('View live')), - messages.button(reverse('wagtailadmin_pages_edit', args=(revision.page.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(revision.page.id,)), _('Edit')) ]) send_notification(revision.id, 'approved', request.user.id) @@ -752,7 +752,7 @@ def reject_moderation(request, revision_id): if request.method == 'POST': revision.reject_moderation() messages.success(request, _("Page '{0}' rejected for publication.").format(revision.page.title), buttons=[ - messages.button(reverse('wagtailadmin_pages_edit', args=(revision.page.id,)), _('Edit')) + messages.button(reverse('wagtailadmin_pages:edit', args=(revision.page.id,)), _('Edit')) ]) send_notification(revision.id, 'rejected', request.user.id) diff --git a/wagtail/wagtaildocs/admin_urls.py b/wagtail/wagtaildocs/admin_urls.py index c26826b8c..18fa3c450 100644 --- a/wagtail/wagtaildocs/admin_urls.py +++ b/wagtail/wagtaildocs/admin_urls.py @@ -3,13 +3,13 @@ from wagtail.wagtaildocs.views import documents, chooser urlpatterns = [ - url(r'^$', documents.index, name='wagtaildocs_index'), - url(r'^add/$', documents.add, name='wagtaildocs_add_document'), - url(r'^edit/(\d+)/$', documents.edit, name='wagtaildocs_edit_document'), - url(r'^delete/(\d+)/$', documents.delete, name='wagtaildocs_delete_document'), + url(r'^$', documents.index, name='index'), + url(r'^add/$', documents.add, name='add'), + url(r'^edit/(\d+)/$', documents.edit, name='edit'), + url(r'^delete/(\d+)/$', documents.delete, name='delete'), - url(r'^chooser/$', chooser.chooser, name='wagtaildocs_chooser'), - url(r'^chooser/(\d+)/$', chooser.document_chosen, name='wagtaildocs_document_chosen'), - url(r'^chooser/upload/$', chooser.chooser_upload, name='wagtaildocs_chooser_upload'), - url(r'^usage/(\d+)/$', documents.usage, name='wagtaildocs_document_usage'), + url(r'^chooser/$', chooser.chooser, name='chooser'), + url(r'^chooser/(\d+)/$', chooser.document_chosen, name='document_chosen'), + url(r'^chooser/upload/$', chooser.chooser_upload, name='chooser_upload'), + url(r'^usage/(\d+)/$', documents.usage, name='document_usage'), ] diff --git a/wagtail/wagtaildocs/models.py b/wagtail/wagtaildocs/models.py index 4d4b3939a..1bb98f180 100644 --- a/wagtail/wagtaildocs/models.py +++ b/wagtail/wagtaildocs/models.py @@ -55,7 +55,7 @@ class Document(models.Model, TagSearchable): @property def usage_url(self): - return reverse('wagtaildocs_document_usage', + return reverse('wagtaildocs:document_usage', args=(self.id,)) def is_editable_by_user(self, user): diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/chooser/chooser.html b/wagtail/wagtaildocs/templates/wagtaildocs/chooser/chooser.html index a30addd4a..9359f1f9a 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/chooser/chooser.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/chooser/chooser.html @@ -12,7 +12,7 @@
{% if uploadform %}
- + {% csrf_token %}
    {% for field in uploadform %} diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html index f9a221bb3..1da34859c 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/add.html @@ -16,7 +16,7 @@ {% include "wagtailadmin/shared/header.html" with title=add_str icon="doc-full-inverse" %}
    - + {% csrf_token %}
      {% for field in form %} diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/confirm_delete.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/confirm_delete.html index 4e4d2134e..99d262817 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/confirm_delete.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/confirm_delete.html @@ -8,7 +8,7 @@

      {% trans "Are you sure you want to delete this document?" %}

      - + {% csrf_token %} diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html index 124cf1993..4f5034053 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/edit.html @@ -18,7 +18,7 @@
      -
      + {% csrf_token %}
      diff --git a/wagtail/wagtaildocs/templates/wagtaildocs/documents/index.html b/wagtail/wagtaildocs/templates/wagtaildocs/documents/index.html index e634d4080..8bacdf626 100644 --- a/wagtail/wagtaildocs/templates/wagtaildocs/documents/index.html +++ b/wagtail/wagtaildocs/templates/wagtaildocs/documents/index.html @@ -5,7 +5,7 @@ {% block extra_js %} """, - urlresolvers.reverse('wagtaildocs_chooser') + urlresolvers.reverse('wagtaildocs:chooser') ) diff --git a/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html b/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html index fbc28c425..c813ecbc2 100644 --- a/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html +++ b/wagtail/wagtailembeds/templates/wagtailembeds/chooser/chooser.html @@ -5,7 +5,7 @@
      -
      + {% csrf_token %}
        {% for field in form %} diff --git a/wagtail/wagtailembeds/urls.py b/wagtail/wagtailembeds/urls.py index 40a97bf35..03c067778 100644 --- a/wagtail/wagtailembeds/urls.py +++ b/wagtail/wagtailembeds/urls.py @@ -3,6 +3,6 @@ from wagtail.wagtailembeds.views import chooser urlpatterns = [ - url(r'^chooser/$', chooser.chooser, name='wagtailembeds_chooser'), - url(r'^chooser/upload/$', chooser.chooser_upload, name='wagtailembeds_chooser_upload'), + url(r'^chooser/$', chooser.chooser, name='chooser'), + url(r'^chooser/upload/$', chooser.chooser_upload, name='chooser_upload'), ] diff --git a/wagtail/wagtailembeds/wagtail_hooks.py b/wagtail/wagtailembeds/wagtail_hooks.py index 863544c5a..f99c2c4fe 100644 --- a/wagtail/wagtailembeds/wagtail_hooks.py +++ b/wagtail/wagtailembeds/wagtail_hooks.py @@ -11,7 +11,7 @@ from wagtail.wagtailembeds.rich_text import MediaEmbedHandler @hooks.register('register_admin_urls') def register_admin_urls(): return [ - url(r'^embeds/', include(urls)), + url(r'^embeds/', include(urls, namespace='wagtailembeds')), ] @@ -26,7 +26,7 @@ def editor_js(): """, settings.STATIC_URL, 'wagtailembeds/js/hallo-plugins/hallo-wagtailembeds.js', - urlresolvers.reverse('wagtailembeds_chooser') + urlresolvers.reverse('wagtailembeds:chooser') ) diff --git a/wagtail/wagtailforms/templates/wagtailforms/list_forms.html b/wagtail/wagtailforms/templates/wagtailforms/list_forms.html index b1958f5b2..9ce794ee0 100644 --- a/wagtail/wagtailforms/templates/wagtailforms/list_forms.html +++ b/wagtail/wagtailforms/templates/wagtailforms/list_forms.html @@ -12,10 +12,10 @@ {% for fp in form_pages %} -

        {{ fp|capfirst }}

        +

        {{ fp|capfirst }}

        - {{ fp.content_type.name |capfirst }} ({{ fp.content_type.app_label }}.{{ fp.content_type.model }}) + {{ fp.content_type.name |capfirst }} ({{ fp.content_type.app_label }}.{{ fp.content_type.model }}) {% endfor %} diff --git a/wagtail/wagtailforms/templates/wagtailforms/results_forms.html b/wagtail/wagtailforms/templates/wagtailforms/results_forms.html index 6cf198553..f6a889a36 100644 --- a/wagtail/wagtailforms/templates/wagtailforms/results_forms.html +++ b/wagtail/wagtailforms/templates/wagtailforms/results_forms.html @@ -2,7 +2,7 @@ {% if form_pages %} {% include "wagtailforms/list_forms.html" %} - {% include "wagtailadmin/shared/pagination_nav.html" with items=form_pages linkurl="wagtailforms_index" %} + {% include "wagtailadmin/shared/pagination_nav.html" with items=form_pages linkurl="wagtailforms:index" %} {% else %}

        {% trans "No form pages have been created." %}

        {% endif %} diff --git a/wagtail/wagtailforms/tests.py b/wagtail/wagtailforms/tests.py index f444a074b..801d8be62 100644 --- a/wagtail/wagtailforms/tests.py +++ b/wagtail/wagtailforms/tests.py @@ -194,7 +194,7 @@ class TestFormsIndex(TestCase): )) def test_forms_index(self): - response = self.client.get(reverse('wagtailforms_index')) + response = self.client.get(reverse('wagtailforms:index')) # Check response self.assertEqual(response.status_code, 200) @@ -205,7 +205,7 @@ class TestFormsIndex(TestCase): self.make_form_pages() # Get page two - response = self.client.get(reverse('wagtailforms_index'), {'p': 2}) + response = self.client.get(reverse('wagtailforms:index'), {'p': 2}) # Check response self.assertEqual(response.status_code, 200) @@ -219,7 +219,7 @@ class TestFormsIndex(TestCase): self.make_form_pages() # Get page two - response = self.client.get(reverse('wagtailforms_index'), {'p': 'Hello world!'}) + response = self.client.get(reverse('wagtailforms:index'), {'p': 'Hello world!'}) # Check response self.assertEqual(response.status_code, 200) @@ -233,7 +233,7 @@ class TestFormsIndex(TestCase): self.make_form_pages() # Get page two - response = self.client.get(reverse('wagtailforms_index'), {'p': 99999}) + response = self.client.get(reverse('wagtailforms:index'), {'p': 99999}) # Check response self.assertEqual(response.status_code, 200) @@ -246,13 +246,13 @@ class TestFormsIndex(TestCase): # Login with as a user without permission to see forms self.client.login(username='eventeditor', password='password') - response = self.client.get(reverse('wagtailforms_index')) + response = self.client.get(reverse('wagtailforms:index')) # Check that the user cannot see the form page self.assertFalse(self.form_page in response.context['form_pages']) def test_can_see_forms_with_permission(self): - response = self.client.get(reverse('wagtailforms_index')) + response = self.client.get(reverse('wagtailforms:index')) # Check that the user can see the form page self.assertIn(self.form_page, response.context['form_pages']) @@ -301,7 +301,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): submission.save() def test_list_submissions(self): - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, ))) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, ))) # Check response self.assertEqual(response.status_code, 200) @@ -309,7 +309,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): self.assertEqual(len(response.context['data_rows']), 2) def test_list_submissions_filtering(self): - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'date_from': '01/01/2014'}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'date_from': '01/01/2014'}) # Check response self.assertEqual(response.status_code, 200) @@ -319,7 +319,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): def test_list_submissions_pagination(self): self.make_list_submissions() - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'p': 2}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'p': 2}) # Check response self.assertEqual(response.status_code, 200) @@ -331,7 +331,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): def test_list_submissions_pagination_invalid(self): self.make_list_submissions() - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'p': 'Hello World!'}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'p': 'Hello World!'}) # Check response self.assertEqual(response.status_code, 200) @@ -343,7 +343,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): def test_list_submissions_pagination_out_of_range(self): self.make_list_submissions() - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'p': 99999}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'p': 99999}) # Check response self.assertEqual(response.status_code, 200) @@ -353,7 +353,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): self.assertEqual(response.context['submissions'].number, response.context['submissions'].paginator.num_pages) def test_list_submissions_csv_export(self): - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'date_from': '01/01/2014', 'action': 'CSV'}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'date_from': '01/01/2014', 'action': 'CSV'}) # Check response self.assertEqual(response.status_code, 200) @@ -371,7 +371,7 @@ class TestFormsSubmissions(TestCase, WagtailTestUtils): unicode_form_submission.submit_time = '2014-01-02T12:00:00.000Z' unicode_form_submission.save() - response = self.client.get(reverse('wagtailforms_list_submissions', args=(self.form_page.id, )), {'date_from': '01/02/2014', 'action': 'CSV'}) + response = self.client.get(reverse('wagtailforms:list_submissions', args=(self.form_page.id, )), {'date_from': '01/02/2014', 'action': 'CSV'}) # Check response self.assertEqual(response.status_code, 200) diff --git a/wagtail/wagtailforms/urls.py b/wagtail/wagtailforms/urls.py index 4cf64ccbe..6d9ec420d 100644 --- a/wagtail/wagtailforms/urls.py +++ b/wagtail/wagtailforms/urls.py @@ -4,6 +4,6 @@ from wagtail.wagtailforms import views urlpatterns = [ - url(r'^$', views.index, name='wagtailforms_index'), - url(r'^submissions/(\d+)/$', views.list_submissions, name='wagtailforms_list_submissions'), + url(r'^$', views.index, name='index'), + url(r'^submissions/(\d+)/$', views.list_submissions, name='list_submissions'), ] diff --git a/wagtail/wagtailforms/wagtail_hooks.py b/wagtail/wagtailforms/wagtail_hooks.py index a527ce49d..a463f6592 100644 --- a/wagtail/wagtailforms/wagtail_hooks.py +++ b/wagtail/wagtailforms/wagtail_hooks.py @@ -9,20 +9,23 @@ from wagtail.wagtailadmin.menu import MenuItem from wagtail.wagtailforms import urls from wagtail.wagtailforms.models import get_forms_for_user + @hooks.register('register_admin_urls') def register_admin_urls(): return [ - url(r'^forms/', include(urls)), + url(r'^forms/', include(urls, namespace='wagtailforms')), ] + class FormsMenuItem(MenuItem): def is_shown(self, request): # show this only if the user has permission to retrieve submissions for at least one form return get_forms_for_user(request.user).exists() + @hooks.register('register_admin_menu_item') def register_forms_menu_item(): - return FormsMenuItem(_('Forms'), urlresolvers.reverse('wagtailforms_index'), name='forms', classnames='icon icon-form', order=700) + return FormsMenuItem(_('Forms'), urlresolvers.reverse('wagtailforms:index'), name='forms', classnames='icon icon-form', order=700) @hooks.register('insert_editor_js') diff --git a/wagtail/wagtailimages/admin_urls.py b/wagtail/wagtailimages/admin_urls.py index a095f9431..61d9291ef 100644 --- a/wagtail/wagtailimages/admin_urls.py +++ b/wagtail/wagtailimages/admin_urls.py @@ -4,21 +4,21 @@ from wagtail.wagtailimages.views import images, chooser, multiple urlpatterns = [ - url(r'^$', images.index, name='wagtailimages_index'), - url(r'^(\d+)/$', images.edit, name='wagtailimages_edit_image'), - url(r'^(\d+)/delete/$', images.delete, name='wagtailimages_delete_image'), - url(r'^(\d+)/generate_url/$', images.url_generator, name='wagtailimages_url_generator'), - url(r'^(\d+)/generate_url/(.*)/$', images.generate_url, name='wagtailimages_generate_url'), - url(r'^(\d+)/preview/(.*)/$', images.preview, name='wagtailimages_preview'), - url(r'^add/$', images.add, name='wagtailimages_add_image'), - url(r'^usage/(\d+)/$', images.usage, name='wagtailimages_image_usage'), + url(r'^$', images.index, name='index'), + url(r'^(\d+)/$', images.edit, name='edit'), + url(r'^(\d+)/delete/$', images.delete, name='delete'), + url(r'^(\d+)/generate_url/$', images.url_generator, name='url_generator'), + url(r'^(\d+)/generate_url/(.*)/$', images.generate_url, name='generate_url'), + url(r'^(\d+)/preview/(.*)/$', images.preview, name='preview'), + url(r'^add/$', images.add, name='add'), + url(r'^usage/(\d+)/$', images.usage, name='image_usage'), - url(r'^multiple/add/$', multiple.add, name='wagtailimages_add_multiple'), - url(r'^multiple/(\d+)/$', multiple.edit, name='wagtailimages_edit_multiple'), - url(r'^multiple/(\d+)/delete/$', multiple.delete, name='wagtailimages_delete_multiple'), + url(r'^multiple/add/$', multiple.add, name='add_multiple'), + url(r'^multiple/(\d+)/$', multiple.edit, name='edit_multiple'), + url(r'^multiple/(\d+)/delete/$', multiple.delete, name='delete_multiple'), - url(r'^chooser/$', chooser.chooser, name='wagtailimages_chooser'), - url(r'^chooser/(\d+)/$', chooser.image_chosen, name='wagtailimages_image_chosen'), - url(r'^chooser/upload/$', chooser.chooser_upload, name='wagtailimages_chooser_upload'), - url(r'^chooser/(\d+)/select_format/$', chooser.chooser_select_format, name='wagtailimages_chooser_select_format'), + url(r'^chooser/$', chooser.chooser, name='chooser'), + url(r'^chooser/(\d+)/$', chooser.image_chosen, name='image_chosen'), + url(r'^chooser/upload/$', chooser.chooser_upload, name='chooser_upload'), + url(r'^chooser/(\d+)/select_format/$', chooser.chooser_select_format, name='chooser_select_format'), ] diff --git a/wagtail/wagtailimages/migrations/0007_image_file_size.py b/wagtail/wagtailimages/migrations/0007_image_file_size.py new file mode 100644 index 000000000..e21800e65 --- /dev/null +++ b/wagtail/wagtailimages/migrations/0007_image_file_size.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailimages', '0006_add_verbose_names'), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='file_size', + field=models.PositiveIntegerField(editable=False, null=True), + ), + ] diff --git a/wagtail/wagtailimages/models.py b/wagtail/wagtailimages/models.py index 5edc7b2b9..032a6cfff 100644 --- a/wagtail/wagtailimages/models.py +++ b/wagtail/wagtailimages/models.py @@ -73,6 +73,20 @@ class AbstractImage(models.Model, TagSearchable): focal_point_width = models.PositiveIntegerField(null=True, blank=True) focal_point_height = models.PositiveIntegerField(null=True, blank=True) + file_size = models.PositiveIntegerField(null=True, editable=False) + + def get_file_size(self): + if self.file_size is None: + try: + self.file_size = self.file.size + except OSError: + # File doesn't exist + return + + self.save(update_fields=['file_size']) + + return self.file_size + def get_usage(self): return get_object_usage(self) diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html index c77190f5d..e42ffe818 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/chooser.html @@ -12,7 +12,7 @@
        {% if uploadform %}
        - + {% csrf_token %}
          {% for field in uploadform %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html index 497211f26..ca81c94f2 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/results.html @@ -16,7 +16,7 @@
            {% for image in images %}
          • - +
            {% image image max-165x165 %}

            {{ image.title|ellipsistrim:60 }}

            diff --git a/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html index 6aed9d005..37e9bdd96 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html +++ b/wagtail/wagtailimages/templates/wagtailimages/chooser/select_format.html @@ -8,7 +8,7 @@ {% image image max-800x600 %}
        - + {% csrf_token %}
          {% for field in form %} @@ -18,4 +18,4 @@
        -
      \ No newline at end of file +
      diff --git a/wagtail/wagtailimages/templates/wagtailimages/homepage/site_summary_images.html b/wagtail/wagtailimages/templates/wagtailimages/homepage/site_summary_images.html index 7414ede22..42d17020d 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/homepage/site_summary_images.html +++ b/wagtail/wagtailimages/templates/wagtailimages/homepage/site_summary_images.html @@ -1,7 +1,7 @@ {% load i18n wagtailadmin_tags %}
    • - + {% blocktrans count counter=total_images with total_images|intcomma as total %} {{ total }} Image {% plural %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/add.html b/wagtail/wagtailimages/templates/wagtailimages/images/add.html index 81a6a2832..d8c88d8a2 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/add.html @@ -16,7 +16,7 @@ {% include "wagtailadmin/shared/header.html" with title=add_str icon="image" %}
      -
      + {% csrf_token %}
        {% for field in form %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html b/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html index ac9915dc8..2d426733d 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/confirm_delete.html @@ -14,7 +14,7 @@

      {% trans "Are you sure you want to delete this image?" %}

      - + {% csrf_token %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/edit.html b/wagtail/wagtailimages/templates/wagtailimages/images/edit.html index 8762bb555..2439f56f4 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/edit.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/edit.html @@ -28,7 +28,7 @@
      {% if url_generator_enabled %} - {% trans "URL Generator" %} + {% trans "URL Generator" %}
      {% endif %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/index.html b/wagtail/wagtailimages/templates/wagtailimages/images/index.html index 2a9d4bf8c..d3d2d10db 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/index.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/index.html @@ -7,7 +7,7 @@ {% block extra_js %} {% endcompress %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/images/usage.html b/wagtail/wagtailimages/templates/wagtailimages/images/usage.html index e8d12f194..2fba022d2 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/images/usage.html +++ b/wagtail/wagtailimages/templates/wagtailimages/images/usage.html @@ -23,7 +23,7 @@ {% for page in used_by %} -

      {{ page.title }}

      +

      {{ page.title }}

      {% if page.get_parent %} diff --git a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html index b9fb8e418..9e6c3cec6 100644 --- a/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html +++ b/wagtail/wagtailimages/templates/wagtailimages/multiple/add.html @@ -18,10 +18,10 @@

      {% trans "Drag and drop images into this area to upload immediately." %}

      {{ help_text }} -

      +
      - +
      {% csrf_token %}
      @@ -72,7 +72,7 @@ {% url 'wagtailadmin_tag_autocomplete' as autocomplete_url %} """, - urlresolvers.reverse('wagtailimages_chooser') + urlresolvers.reverse('wagtailimages:chooser') ) diff --git a/wagtail/wagtailredirects/templates/wagtailredirects/add.html b/wagtail/wagtailredirects/templates/wagtailredirects/add.html index 5fcf22e08..807972a9e 100644 --- a/wagtail/wagtailredirects/templates/wagtailredirects/add.html +++ b/wagtail/wagtailredirects/templates/wagtailredirects/add.html @@ -6,7 +6,7 @@ {% trans "Add redirect" as add_red_str %} {% include "wagtailadmin/shared/header.html" with title=add_red_str icon="redirect" %} -
      + {% csrf_token %} {{ edit_handler.render_form_content }} diff --git a/wagtail/wagtailredirects/templates/wagtailredirects/confirm_delete.html b/wagtail/wagtailredirects/templates/wagtailredirects/confirm_delete.html index e693f7180..638833923 100644 --- a/wagtail/wagtailredirects/templates/wagtailredirects/confirm_delete.html +++ b/wagtail/wagtailredirects/templates/wagtailredirects/confirm_delete.html @@ -8,7 +8,7 @@

      {% trans "Are you sure you want to delete this redirect?" %}

      - + {% csrf_token %} diff --git a/wagtail/wagtailredirects/templates/wagtailredirects/edit.html b/wagtail/wagtailredirects/templates/wagtailredirects/edit.html index 88ddc9274..1f2347431 100644 --- a/wagtail/wagtailredirects/templates/wagtailredirects/edit.html +++ b/wagtail/wagtailredirects/templates/wagtailredirects/edit.html @@ -6,13 +6,13 @@ {% trans "Editing" as editing_str %} {% include "wagtailadmin/shared/header.html" with title=editing_str subtitle=redirect.title icon="redirect" %} -
      + {% csrf_token %} {{ edit_handler.render_form_content }}
      diff --git a/wagtail/wagtailredirects/templates/wagtailredirects/index.html b/wagtail/wagtailredirects/templates/wagtailredirects/index.html index ef9b349dd..42c0dea82 100644 --- a/wagtail/wagtailredirects/templates/wagtailredirects/index.html +++ b/wagtail/wagtailredirects/templates/wagtailredirects/index.html @@ -6,7 +6,7 @@ {% block extra_js %}