From dcb67dcfdf011c6c118e3a1b2f3a438801681782 Mon Sep 17 00:00:00 2001 From: Andy Babic Date: Wed, 13 Apr 2016 07:01:04 +0100 Subject: [PATCH] Added wagtailmodeladmin to contrib with docs --- CHANGELOG.txt | 3 +- CONTRIBUTORS.rst | 1 + docs/reference/contrib/index.rst | 9 +- docs/reference/contrib/modeladmin.rst | 220 +++++ docs/releases/1.5.rst | 6 + gulpfile.js/config.js | 7 + wagtail/contrib/modeladmin/.gitignore | 1 + wagtail/contrib/modeladmin/__init__.py | 0 wagtail/contrib/modeladmin/forms.py | 30 + wagtail/contrib/modeladmin/helpers.py | 362 +++++++ wagtail/contrib/modeladmin/menus.py | 54 + wagtail/contrib/modeladmin/options.py | 588 +++++++++++ .../modeladmin/scss/choose_parent_page.scss | 6 + .../static_src/modeladmin/scss/index.scss | 147 +++ .../templates/modeladmin/choose_parent.html | 43 + .../templates/modeladmin/create.html | 41 + .../templates/modeladmin/delete.html | 30 + .../modeladmin/templates/modeladmin/edit.html | 18 + .../modeladmin/includes/breadcrumb.html | 5 + .../templates/modeladmin/includes/button.html | 1 + .../templates/modeladmin/includes/filter.html | 8 + .../modeladmin/includes/result_list.html | 28 + .../modeladmin/includes/result_row.html | 4 + .../modeladmin/includes/result_row_value.html | 11 + .../modeladmin/includes/search_form.html | 20 + .../templates/modeladmin/index.html | 93 ++ .../templates/modeladmin/inspect.html | 55 ++ .../modeladmin/templatetags/__init__.py | 0 .../templatetags/modeladmin_tags.py | 188 ++++ wagtail/contrib/modeladmin/tests/__init__.py | 0 .../modeladmin/tests/test_page_modeladmin.py | 221 +++++ .../tests/test_simple_modeladmin.py | 357 +++++++ wagtail/contrib/modeladmin/views.py | 932 ++++++++++++++++++ wagtail/tests/modeladmintest/__init__.py | 0 wagtail/tests/modeladmintest/apps.py | 7 + .../fixtures/modeladmintest_test.json | 66 ++ .../modeladmintest/migrations/0001_initial.py | 35 + .../modeladmintest/migrations/__init__.py | 0 wagtail/tests/modeladmintest/models.py | 22 + wagtail/tests/modeladmintest/wagtail_hooks.py | 57 ++ wagtail/tests/settings.py | 3 +- .../wagtailadmin/pages/confirm_delete.html | 1 + .../wagtailadmin/pages/confirm_unpublish.html | 1 + .../templates/wagtailadmin/pages/copy.html | 1 + .../templates/wagtailadmin/pages/create.html | 1 + .../templates/wagtailadmin/pages/edit.html | 1 + wagtail/wagtailadmin/views/pages.py | 56 +- 47 files changed, 3732 insertions(+), 8 deletions(-) create mode 100644 docs/reference/contrib/modeladmin.rst create mode 100644 wagtail/contrib/modeladmin/.gitignore create mode 100644 wagtail/contrib/modeladmin/__init__.py create mode 100644 wagtail/contrib/modeladmin/forms.py create mode 100644 wagtail/contrib/modeladmin/helpers.py create mode 100644 wagtail/contrib/modeladmin/menus.py create mode 100644 wagtail/contrib/modeladmin/options.py create mode 100644 wagtail/contrib/modeladmin/static_src/modeladmin/scss/choose_parent_page.scss create mode 100644 wagtail/contrib/modeladmin/static_src/modeladmin/scss/index.scss create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/choose_parent.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/create.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/delete.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/edit.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/breadcrumb.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/button.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/filter.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/result_list.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row_value.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/includes/search_form.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/index.html create mode 100644 wagtail/contrib/modeladmin/templates/modeladmin/inspect.html create mode 100644 wagtail/contrib/modeladmin/templatetags/__init__.py create mode 100644 wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py create mode 100644 wagtail/contrib/modeladmin/tests/__init__.py create mode 100644 wagtail/contrib/modeladmin/tests/test_page_modeladmin.py create mode 100644 wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py create mode 100644 wagtail/contrib/modeladmin/views.py create mode 100644 wagtail/tests/modeladmintest/__init__.py create mode 100644 wagtail/tests/modeladmintest/apps.py create mode 100644 wagtail/tests/modeladmintest/fixtures/modeladmintest_test.json create mode 100644 wagtail/tests/modeladmintest/migrations/0001_initial.py create mode 100644 wagtail/tests/modeladmintest/migrations/__init__.py create mode 100644 wagtail/tests/modeladmintest/models.py create mode 100644 wagtail/tests/modeladmintest/wagtail_hooks.py diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8567985da..bacacbd04 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -4,11 +4,12 @@ Changelog 1.5 (xx.xx.xxxx) ~~~~~~~~~~~~~~~~ + * Added wagtail.contrib.modeladmin, an app for configuring arbitrary Django models to be edited through the Wagtail admin (Andy Babic) + * The "dynamic serve view" for images has been greatly improved. See release notes for details * Moved lesser-user actions in the page explorer into a 'More' dropdown * Added a hook `register_page_listing_buttons` for adding action buttons to the page explorer * Added 'revisions' action to pages list (Roel Bruggink) * Added jinja2 support for the ``settings`` template tag (Tim Heap) - * The "dynamic serve view" for images has been greatly improved. See release notes for details * Added a hook `insert_global_admin_js` for inserting custom JavaScript throughout the admin backend (Tom Dyson) * Recognise instagram embed URLs with `www` prefix (Matt Westcott) * The type of the ``search_fields`` attribute on ``Page`` models (and other searchable models) has changed from a tuple to a list (Tim Heap) diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index ca9da90fd..2ebbfd56e 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -122,6 +122,7 @@ Contributors * Janneke Janssen * Roel Bruggink * Yannick Chabbert +* Andy Babic Translators =========== diff --git a/docs/reference/contrib/index.rst b/docs/reference/contrib/index.rst index e1b5c183e..cd042a1da 100644 --- a/docs/reference/contrib/index.rst +++ b/docs/reference/contrib/index.rst @@ -14,6 +14,7 @@ Wagtail ships with a variety of extra optional modules. frontendcache routablepage api/index + modeladmin searchpromotions @@ -59,7 +60,13 @@ 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:`modeladmin` +----------------- + +A module allowing for more customisable representation and management of custom models in Wagtail's admin area. + + :doc:`searchpromotions` ----------------------- -A module for managing "Promoted Search Results" +A module for managing "Promoted Search Results". diff --git a/docs/reference/contrib/modeladmin.rst b/docs/reference/contrib/modeladmin.rst new file mode 100644 index 000000000..de6773fef --- /dev/null +++ b/docs/reference/contrib/modeladmin.rst @@ -0,0 +1,220 @@ + +.. _modeladmin_intro: + +===================== +``ModelAdmin`` +===================== + +The ``modeladmin`` module allows you to create customisable listing +pages for any model in your Wagtail project, and add navigation elements to the +Wagtail admin area so that you can reach them. Simply extend the ``ModelAdmin`` +class, override a few attributes to suit your needs, register it with Wagtail +using an easy one-line method (you can copy and paste from the examples below), +and you're good to go. + +You can use it with any Django model (it doesn’t need to extend ``Page`` or +be registered as a ``Snippet``), and it won’t interfere with any of the +existing admin functionality that Wagtail provides. + +.. _modeladmin_features: + +A full list of features +----------------------- + +- A customisable list view, allowing you to control what values are displayed + for each row, available options for result filtering, default ordering, and + more. +- Access your list views from the Wagtail admin menu easily with automatically + generated menu items, with automatic 'active item' highlighting. Control the + label text and icons used with easy-to-change attributes on your class. +- An additional ``ModelAdminGroup`` class, that allows you to group your + related models, and list them together in their own submenu, for a more + logical user experience. +- Simple, robust **add** and **edit** views for your non-Page models that use + the panel configurations defined on your model using Wagtail's edit panels. +- For Page models, the system directs to Wagtail's existing add and + edit views, and returns you back to the correct list page, for a seamless + experience. +- Full respect for permissions assigned to your Wagtail users and groups. Users + will only be able to do what you want them to! +- All you need to easily hook your ``ModelAdmin`` classes into Wagtail, taking + care of URL registration, menu changes, and registering any missing model + permissions, so that you can assign them to Groups. +- **Built to be customisable** - While ``modeladmin`` provides a solid + experience out of the box, you can easily use your own templates, and the + ``ModelAdmin`` class has a large number of methods that you can override or + extend, allowing you to customise the behaviour to a greater degree. + + +.. _modeladmin_usage: + +Installation +------------ + +Add ``wagtail.contrib.modeladmin`` to your ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS = [ + ... + 'wagtail.contrib.modeladmin', + ] + +How to use +---------- + +.. _modeladmin_example_simple: + +A simple example +^^^^^^^^^^^^^^^^ + +You have a model in your app, and you want a listing page specifically for that +model, with a menu item added to the menu in the Wagtail admin area so that you +can get to it. + +``wagtail_hooks.py`` in your app directory would look something like this: + +.. code-block:: python + + from wagtail.contrib.modeladmin.options import ( + ModelAdmin, modeladmin_register) + from .models import MyPageModel + + + class MyPageModelAdmin(ModelAdmin): + model = MyPageModel + menu_label = 'Page Model' # ditch this to use verbose_name_plural from model + menu_icon = 'date' # change as required + menu_order = 200 # will put in 3rd place (000 being 1st, 100 2nd) + add_to_settings_menu = False # or True to add your model to the Settings sub-menu + list_display = ('title', 'example_field2', 'example_field3', 'live') + list_filter = ('live', 'example_field2', 'example_field3') + search_fields = ('title',) + + # Now you just need to register your customised ModelAdmin class with Wagtail + modeladmin_register(MyPageModelAdmin) + + +.. _modeladmin_example_complex: + +A more complicated example +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +You have an app with several models that you want to show grouped together in +Wagtail's admin menu. Some of the models might extend Page, and others might +be simpler models, perhaps registered as Snippets, perhaps not. No problem! +ModelAdminGroup allows you to group them all together nicely. + +``wagtail_hooks.py`` in your app directory would look something like this: + +.. code-block:: python + + from wagtail.contrib.modeladmin.options import ( + ModelAdmin, ModelAdminGroup, modeladmin_register) + from .models import ( + MyPageModel, MyOtherPageModel, MySnippetModel, SomeOtherModel) + + + class MyPageModelAdmin(ModelAdmin): + model = MyPageModel + menu_label = 'Page Model' # ditch this to use verbose_name_plural from model + menu_icon = 'doc-full-inverse' # change as required + list_display = ('title', 'example_field2', 'example_field3', 'live') + list_filter = ('live', 'example_field2', 'example_field3') + search_fields = ('title',) + + + class MyOtherPageModelAdmin(ModelAdmin): + model = MyOtherPageModel + menu_label = 'Other Page Model' # ditch this to use verbose_name_plural from model + menu_icon = 'doc-full-inverse' # change as required + list_display = ('title', 'example_field2', 'example_field3', 'live') + list_filter = ('live', 'example_field2', 'example_field3') + search_fields = ('title',) + + + class MySnippetModelAdmin(ModelAdmin): + model = MySnippetModel + menu_label = 'Snippet Model' # ditch this to use verbose_name_plural from model + menu_icon = 'snippet' # change as required + list_display = ('title', 'example_field2', 'example_field3') + list_filter = (example_field2', 'example_field3') + search_fields = ('title',) + + + class SomeOtherModelAdmin(ModelAdmin): + model = SomeOtherModel + menu_label = 'Some other model' # ditch this to use verbose_name_plural from model + menu_icon = 'snippet' # change as required + list_display = ('title', 'example_field2', 'example_field3') + list_filter = (example_field2', 'example_field3') + search_fields = ('title',) + + + class MyModelAdminGroup(ModelAdminGroup): + menu_label = 'My App' + menu_icon = 'folder-open-inverse' # change as required + menu_order = 200 # will put in 3rd place (000 being 1st, 100 2nd) + items = (MyPageModelAdmin, MyOtherPageModelAdmin, MySnippetModelAdmin, SomeOtherModelAdmin) + + # When using a ModelAdminGroup class to group several ModelAdmin classes together, + # you only need to register the ModelAdminGroup class with Wagtail: + modeladmin_register(MyModelAdminGroup) + + +.. _modeladmin_multi_registeration: + +Registering multiple classes in one ``wagtail_hooks.py`` file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you have an app with more than one model that you wish to manage, or even +multiple models you wish to group together with ``ModelAdminGroup`` classes, +that's possible. Just register each of your ModelAdmin classes using +``modeladmin_register``, and they'll work as expected. + +.. code-block:: python + + class MyPageModelAdmin(ModelAdmin): + model = MyPageModel + ... + + class MyOtherPageModelAdmin(ModelAdmin): + model = MyOtherPageModel + ... + + class MyModelAdminGroup(ModelAdminGroup): + label = _("Group 1") + items = (ModelAdmin1, ModelAdmin2) + ... + + class MyOtherModelAdminGroup(ModelAdminGroup): + label = _("Group 2") + items = (ModelAdmin3, ModelAdmin4) + ... + + modeladmin_register(MyPageModelAdmin) + modeladmin_register(MyOtherPageModelAdmin) + modeladmin_register(MyModelAdminGroup) + modeladmin_register(MyOtherModelAdminGroup) + + +Supported list options +----------------------- + +With the exception of bulk actions and date hierarchy, the ``ModelAdmin`` class +offers similar list functionality to Django's ``ModelAdmin`` class, providing: + +- control over what values are displayed (via the ``list_display`` attribute) +- control over default ordering (via the ``ordering`` attribute) +- customisable model-specific text search (via the ``search_fields`` attribute) +- customisable filters (via the ``list_filter`` attribue) + +``list_display`` supports the same fields and methods as Django's ModelAdmin +class (including ``short_description`` and ``admin_order_field`` on custom +methods), giving you lots of flexibility when it comes to output. +`Read more about list_display in the Django docs `_. + +``list_filter`` supports the same field types as Django's ModelAdmin class, +giving your users an easy way to find what they're looking for. +`Read more about list_filter in the Django docs `_. + diff --git a/docs/releases/1.5.rst b/docs/releases/1.5.rst index ac582b920..d51cecc30 100644 --- a/docs/releases/1.5.rst +++ b/docs/releases/1.5.rst @@ -10,6 +10,12 @@ Wagtail 1.5 release notes - IN DEVELOPMENT What's new ========== +ModelAdmin +~~~~~~~~~~ + +Wagtail now includes an app ``wagtail.contrib.modeladmin`` (previously available separately as the `wagtailmodeladmin `_ package) which allows you to configure arbitrary Django models to be listed, added and edited through the Wagtail admin. See :doc:`/reference/contrib/modeladmin` for full documentation. This feature was developed by Andy Babic. + + Improvements to the "Image serve view" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/gulpfile.js/config.js b/gulpfile.js/config.js index 192cd99cc..e1b7dd635 100644 --- a/gulpfile.js/config.js +++ b/gulpfile.js/config.js @@ -33,6 +33,13 @@ var apps = [ new App('wagtail/contrib/settings', { 'appName': 'wagtailsettings', }), + new App('wagtail/contrib/modeladmin', { + 'scss': [ + 'modeladmin/scss/index.scss', + 'modeladmin/scss/choose_parent_page.scss', + + ], + }), ]; module.exports = { diff --git a/wagtail/contrib/modeladmin/.gitignore b/wagtail/contrib/modeladmin/.gitignore new file mode 100644 index 000000000..980c85122 --- /dev/null +++ b/wagtail/contrib/modeladmin/.gitignore @@ -0,0 +1 @@ +static/ diff --git a/wagtail/contrib/modeladmin/__init__.py b/wagtail/contrib/modeladmin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/modeladmin/forms.py b/wagtail/contrib/modeladmin/forms.py new file mode 100644 index 000000000..4a2414a04 --- /dev/null +++ b/wagtail/contrib/modeladmin/forms.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import, unicode_literals + +from django import forms +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ + +from wagtail.wagtailcore.models import Page + + +class PageChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + bits = [] + for ancestor in obj.get_ancestors(inclusive=True).exclude(depth=1): + bits.append(ancestor.title) + return mark_safe(''.join(bits)) + + +class ParentChooserForm(forms.Form): + parent_page = PageChoiceField( + label=_('Parent page'), + required=True, + empty_label=None, + queryset=Page.objects.none(), + widget=forms.RadioSelect(), + ) + + def __init__(self, valid_parents_qs, *args, **kwargs): + self.valid_parents_qs = valid_parents_qs + super(ParentChooserForm, self).__init__(*args, **kwargs) + self.fields['parent_page'].queryset = self.valid_parents_qs diff --git a/wagtail/contrib/modeladmin/helpers.py b/wagtail/contrib/modeladmin/helpers.py new file mode 100644 index 000000000..b40fa43a0 --- /dev/null +++ b/wagtail/contrib/modeladmin/helpers.py @@ -0,0 +1,362 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.admin.utils import quote +from django.contrib.auth import get_permission_codename +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.utils.encoding import force_text +from django.utils.functional import cached_property +from django.utils.http import urlquote +from django.utils.translation import ugettext as _ + +from wagtail.wagtailcore.models import Page + + +class AdminURLHelper(object): + + def __init__(self, model): + self.model = model + self.opts = model._meta + + def _get_action_url_pattern(self, action): + if action == 'index': + return r'^%s/%s/$' % (self.opts.app_label, self.opts.model_name) + return r'^%s/%s/%s/$' % (self.opts.app_label, self.opts.model_name, + action) + + def _get_object_specific_action_url_pattern(self, action): + return r'^%s/%s/%s/(?P[-\w]+)/$' % ( + self.opts.app_label, self.opts.model_name, action) + + def get_action_url_pattern(self, action): + if action in ('create', 'choose_parent', 'index'): + return self._get_action_url_pattern(action) + return self._get_object_specific_action_url_pattern(action) + + def get_action_url_name(self, action): + return '%s_%s_modeladmin_%s' % ( + self.opts.app_label, self.opts.model_name, action) + + def get_action_url(self, action, *args, **kwargs): + if action in ('create', 'choose_parent', 'index'): + return reverse(self.get_action_url_name(action)) + url_name = self.get_action_url_name(action) + return reverse(url_name, args=args, kwargs=kwargs) + + @cached_property + def index_url(self): + return self.get_action_url('index') + + @cached_property + def create_url(self): + return self.get_action_url('create') + + +class PageAdminURLHelper(AdminURLHelper): + + def get_action_url(self, action, *args, **kwargs): + if action in ('add', 'edit', 'delete', 'unpublish', 'copy'): + url_name = 'wagtailadmin_pages:%s' % action + target_url = reverse(url_name, args=args, kwargs=kwargs) + return '%s?next=%s' % (target_url, urlquote(self.index_url)) + return super(PageAdminURLHelper, self).get_action_url(action, *args, + **kwargs) + + +class PermissionHelper(object): + """ + Provides permission-related helper functions to help determine what a + user can do with a 'typical' model (where permissions are granted + model-wide), and to a specific instance of that model. + """ + + def __init__(self, model, inspect_view_enabled=False): + self.model = model + self.opts = model._meta + self.inspect_view_enabled = inspect_view_enabled + + def get_all_model_permissions(self): + """ + Return a queryset of all Permission objects pertaining to the `model` + specified at initialisation. + """ + + return Permission.objects.filter( + content_type__app_label=self.opts.app_label, + content_type__model=self.opts.model_name, + ) + + def get_perm_codename(self, action): + return get_permission_codename(action, self.opts) + + def user_has_specific_permission(self, user, perm_codename): + """ + Combine `perm_codename` with `self.opts.app_label` to call the provided + Django user's built-in `has_perm` method. + """ + + return user.has_perm("%s.%s" % (self.opts.app_label, perm_codename)) + + def user_has_any_permissions(self, user): + """ + Return a boolean to indicate whether `user` has any model-wide + permissions + """ + for perm in self.get_all_model_permissions().values('codename'): + if self.user_has_specific_permission(user, perm['codename']): + return True + return False + + def user_can_list(self, user): + """ + Return a boolean to indicate whether `user` is permitted to access the + list view for self.model + """ + return self.user_has_any_permissions(user) + + def user_can_create(self, user): + """ + Return a boolean to indicate whether `user` is permitted to create new + instances of `self.model` + """ + perm_codename = self.get_perm_codename('add') + return self.user_has_specific_permission(user, perm_codename) + + def user_can_inspect_obj(self, user, obj): + """ + Return a boolean to indicate whether `user` is permitted to 'inspect' + a specific `self.model` instance. + """ + return self.inspect_view_enabled and self.user_has_any_permissions( + user) + + def user_can_edit_obj(self, user, obj): + """ + Return a boolean to indicate whether `user` is permitted to 'change' + a specific `self.model` instance. + """ + perm_codename = self.get_perm_codename('change') + return self.user_has_specific_permission(user, perm_codename) + + def user_can_delete_obj(self, user, obj): + """ + Return a boolean to indicate whether `user` is permitted to 'delete' + a specific `self.model` instance. + """ + perm_codename = self.get_perm_codename('delete') + return self.user_has_specific_permission(user, perm_codename) + + def user_can_unpublish_obj(self, user, obj): + return False + + def user_can_copy_obj(self, user, obj): + return False + + +class PagePermissionHelper(PermissionHelper): + """ + Provides permission-related helper functions to help determine what + a user can do with a model extending Wagtail's Page model. It differs + from `PermissionHelper`, because model-wide permissions aren't really + relevant. We generally need to determine permissions on an + object-specific basis. + """ + + def get_valid_parent_pages(self, user): + """ + Identifies possible parent pages for the current user by first looking + at allowed_parent_page_models() on self.model to limit options to the + correct type of page, then checking permissions on those individual + pages to make sure we have permission to add a subpage to it. + """ + # Get queryset of pages where this page type can be added + allowed_parent_page_content_types = list(ContentType.objects.get_for_models(*self.model.allowed_parent_page_models()).values()) + allowed_parent_pages = Page.objects.filter(content_type__in=allowed_parent_page_content_types) + + # Get queryset of pages where the user has permission to add subpages + if user.is_superuser: + pages_where_user_can_add = Page.objects.all() + else: + pages_where_user_can_add = Page.objects.none() + user_perms = UserPagePermissionsProxy(user) + + for perm in user_perms.permissions.filter(permission_type='add'): + # user has add permission on any subpage of perm.page + # (including perm.page itself) + pages_where_user_can_add |= Page.objects.descendant_of(perm.page, inclusive=True) + + # Combine them + return allowed_parent_pages & pages_where_user_can_add + + def user_can_list(self, user): + """ + For models extending Page, permitted actions are determined by + permissions on individual objects. Rather than check for change + permissions on every object individually (which would be quite + resource intensive), we simply always allow the list view to be + viewed, and limit further functionality when relevant. + """ + return True + + def user_can_create(self, user): + """ + For models extending Page, whether or not a page of this type can be + added somewhere in the tree essentially determines the add permission, + rather than actual model-wide permissions + """ + return self.get_valid_parent_pages(user).exists() + + def user_can_edit_obj(self, user, obj): + perms = obj.permissions_for_user(user) + return perms.can_edit() + + def user_can_delete_obj(self, user, obj): + perms = obj.permissions_for_user(user) + return perms.can_delete() + + def user_can_publish_obj(self, user, obj): + perms = obj.permissions_for_user(user) + return obj.live and perms.can_unpublish() + + def user_can_copy_obj(self, user, obj): + parent_page = obj.get_parent() + return parent_page.permissions_for_user(user).can_publish_subpage() + + +class ButtonHelper(object): + + default_button_classnames = ['button'] + add_button_classnames = ['bicolor', 'icon', 'icon-plus'] + inspect_button_classnames = [] + edit_button_classnames = [] + delete_button_classnames = ['no'] + + def __init__(self, view, request): + self.view = view + self.request = request + self.model = view.model + self.opts = view.model._meta + self.verbose_name = force_text(self.opts.verbose_name) + self.verbose_name_plural = force_text(self.opts.verbose_name_plural) + self.permission_helper = view.permission_helper + self.url_helper = view.url_helper + + def finalise_classname(self, classnames_add=[], classnames_exclude=[]): + combined = self.default_button_classnames + classnames_add + finalised = [cn for cn in combined if cn not in classnames_exclude] + return ' '.join(finalised) + + def add_button(self, classnames_add=[], classnames_exclude=[]): + classnames = self.add_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.create_url, + 'label': _('Add %s') % self.verbose_name, + 'classname': cn, + 'title': _('Add a new %s') % self.verbose_name, + } + + def inspect_button(self, pk, classnames_add=[], classnames_exclude=[]): + classnames = self.inspect_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.get_action_url('inspect', quote(pk)), + 'label': _('Inspect'), + 'classname': cn, + 'title': _('Inspect this %s') % self.verbose_name, + } + + def edit_button(self, pk, classnames_add=[], classnames_exclude=[]): + classnames = self.edit_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.get_action_url('edit', quote(pk)), + 'label': _('Edit'), + 'classname': cn, + 'title': _('Edit this %s') % self.verbose_name, + } + + def delete_button(self, pk, classnames_add=[], classnames_exclude=[]): + classnames = self.delete_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.get_action_url('delete', quote(pk)), + 'label': _('Delete'), + 'classname': cn, + 'title': _('Delete this %s') % self.verbose_name, + } + + def get_buttons_for_obj(self, obj, exclude=[], classnames_add=[], + classnames_exclude=[]): + ph = self.permission_helper + usr = self.request.user + pk = quote(getattr(obj, self.opts.pk.attname)) + btns = [] + if('inspect' not in exclude and ph.user_can_inspect_obj(usr, obj)): + btns.append( + self.inspect_button(pk, classnames_add, classnames_exclude) + ) + if('edit' not in exclude and ph.user_can_edit_obj(usr, obj)): + btns.append( + self.edit_button(pk, classnames_add, classnames_exclude) + ) + if('delete' not in exclude and ph.user_can_delete_obj(usr, obj)): + btns.append( + self.delete_button(pk, classnames_add, classnames_exclude) + ) + return btns + + +class PageButtonHelper(ButtonHelper): + + unpublish_button_classnames = [] + copy_button_classnames = [] + + def unpublish_button(self, pk, classnames_add=[], classnames_exclude=[]): + classnames = self.unpublish_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.get_action_url('unpublish', quote(pk)), + 'label': _('Unpublish'), + 'classname': cn, + 'title': _('Unpublish this %s') % self.verbose_name, + } + + def copy_button(self, pk, classnames_add=[], classnames_exclude=[]): + classnames = self.copy_button_classnames + classnames_add + cn = self.finalise_classname(classnames, classnames_exclude) + return { + 'url': self.url_helper.get_action_url('copy', quote(pk)), + 'label': _('Copy'), + 'classname': cn, + 'title': _('Copy this %s') % self.verbose_name, + } + + def get_buttons_for_obj(self, obj, exclude=[], classnames_add=[], + classnames_exclude=[]): + ph = self.permission_helper + usr = self.request.user + pk = quote(getattr(obj, self.opts.pk.attname)) + btns = [] + if('inspect' not in exclude and ph.user_can_inspect_obj(usr, obj)): + btns.append( + self.inspect_button(pk, classnames_add, classnames_exclude) + ) + if('edit' not in exclude and ph.user_can_edit_obj(usr, obj)): + btns.append( + self.edit_button(pk, classnames_add, classnames_exclude) + ) + if('copy' not in exclude and ph.user_can_copy_obj(usr, obj)): + btns.append( + self.copy_button(pk, classnames_add, classnames_exclude) + ) + if('unpublish' not in exclude and ph.user_can_unpublish_obj(usr, obj)): + btns.append( + self.unpublish_button(pk, classnames_add, classnames_exclude) + ) + if('delete' not in exclude and ph.user_can_delete_obj(usr, obj)): + btns.append( + self.delete_button(pk, classnames_add, classnames_exclude) + ) + return btns diff --git a/wagtail/contrib/modeladmin/menus.py b/wagtail/contrib/modeladmin/menus.py new file mode 100644 index 000000000..e2e266f77 --- /dev/null +++ b/wagtail/contrib/modeladmin/menus.py @@ -0,0 +1,54 @@ +from __future__ import absolute_import, unicode_literals + +from wagtail.wagtailadmin.menu import Menu, MenuItem, SubmenuMenuItem + + +class ModelAdminMenuItem(MenuItem): + """ + A sub-class of wagtail's MenuItem, used by PageModelAdmin to add a link + to its listing page + """ + def __init__(self, model_admin, order): + self.model_admin = model_admin + url = model_admin.url_helper.index_url + classnames = 'icon icon-%s' % model_admin.get_menu_icon() + super(ModelAdminMenuItem, self).__init__( + label=model_admin.get_menu_label(), url=url, + classnames=classnames, order=order) + + def is_shown(self, request): + return self.model_admin.permission_helper.user_can_list(request.user) + + +class GroupMenuItem(SubmenuMenuItem): + """ + A sub-class of wagtail's SubmenuMenuItem, used by ModelAdminGroup to add a + link to the admin menu with its own submenu, linking to various listing + pages + """ + def __init__(self, modeladmingroup, order, menu): + classnames = 'icon icon-%s' % modeladmingroup.get_menu_icon() + super(GroupMenuItem, self).__init__( + label=modeladmingroup.get_menu_label(), menu=menu, + classnames=classnames, order=order, ) + + def is_shown(self, request): + """ + If there aren't any visible items in the submenu, don't bother to show + this menu item + """ + for menuitem in self.menu._registered_menu_items: + if menuitem.is_shown(request): + return True + return False + + +class SubMenu(Menu): + """ + A sub-class of wagtail's Menu, used by AppModelAdmin. We just want to + override __init__, so that we can specify the items to include on + initialisation + """ + def __init__(self, menuitem_list): + self._registered_menu_items = menuitem_list + self.construct_hook_name = None diff --git a/wagtail/contrib/modeladmin/options.py b/wagtail/contrib/modeladmin/options.py new file mode 100644 index 000000000..ad4206fe5 --- /dev/null +++ b/wagtail/contrib/modeladmin/options.py @@ -0,0 +1,588 @@ +from __future__ import absolute_import, unicode_literals + +from django.conf.urls import url +from django.contrib.auth.models import Permission +from django.core.exceptions import ImproperlyConfigured +from django.db.models import Model +from django.forms.widgets import flatatt +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext_lazy as _ + +from wagtail.wagtailcore import hooks +from wagtail.wagtailcore.models import Page +from wagtail.wagtailimages.models import Filter + +from .helpers import ( + AdminURLHelper, ButtonHelper, PageAdminURLHelper, PageButtonHelper, PagePermissionHelper, + PermissionHelper) +from .menus import GroupMenuItem, ModelAdminMenuItem, SubMenu +from .views import ChooseParentView, CreateView, DeleteView, EditView, IndexView, InspectView + + +class WagtailRegisterable(object): + """ + Base class, providing a more convenient way for ModelAdmin or + ModelAdminGroup instances to be registered with Wagtail's admin area. + """ + add_to_settings_menu = False + + def register_with_wagtail(self): + + @hooks.register('register_permissions') + def register_permissions(): + return self.get_permissions_for_registration() + + @hooks.register('register_admin_urls') + def register_admin_urls(): + return self.get_admin_urls_for_registration() + + menu_hook = ( + 'register_settings_menu_item' if self.add_to_settings_menu else + 'register_admin_menu_item' + ) + + @hooks.register(menu_hook) + def register_admin_menu_item(): + return self.get_menu_item() + + +class ThumbnailMixin(object): + """ + Mixin class to help display thumbnail images in ModelAdmin listing results. + `thumb_image_field_name` must be overridden to name a ForeignKey field on + your model, linking to `wagtailimages.Image`. + """ + thumb_image_field_name = 'image' + thumb_image_filter_spec = 'fill-100x100' + thumb_image_width = 50 + thumb_classname = 'admin-thumb' + thumb_col_header_text = _('image') + thumb_default = None + + def admin_thumb(self, obj): + try: + image = getattr(obj, self.thumb_image_field_name, None) + except AttributeError: + raise ImproperlyConfigured( + u"The `thumb_image_field_name` attribute on your `%s` class " + "must name a field on your model." % self.__class__.__name__ + ) + + img_attrs = { + 'src': self.thumb_default, + 'width': self.thumb_image_width, + 'class': self.thumb_classname, + } + if image: + fltr, _ = Filter.objects.get_or_create( + spec=self.thumb_image_filter_spec) + img_attrs.update({'src': image.get_rendition(fltr).url}) + return mark_safe(''.format(flatatt(img_attrs))) + elif self.thumb_default: + return mark_safe(''.format(flatatt(img_attrs))) + return '' + admin_thumb.short_description = thumb_col_header_text + + +class ModelAdmin(WagtailRegisterable): + """ + The core modeladmin class. It provides an alternative means to + list and manage instances of a given 'model' within Wagtail's admin area. + It is essentially comprised of attributes and methods that allow a degree + of control over how the data is represented, and other methods to make the + additional functionality available via various Wagtail hooks. + """ + + model = None + menu_label = None + menu_icon = None + menu_order = None + list_display = ('__str__',) + list_display_add_buttons = None + inspect_view_fields = [] + inspect_view_fields_exclude = [] + inspect_view_enabled = False + empty_value_display = '-' + list_filter = () + list_select_related = False + list_per_page = 100 + search_fields = None + ordering = None + parent = None + index_view_class = IndexView + create_view_class = CreateView + edit_view_class = EditView + inspect_view_class = InspectView + delete_view_class = DeleteView + choose_parent_view_class = ChooseParentView + index_template_name = '' + create_template_name = '' + edit_template_name = '' + inspect_template_name = '' + delete_template_name = '' + choose_parent_template_name = '' + permission_helper_class = None + url_helper_class = None + button_helper_class = None + index_view_extra_css = [] + index_view_extra_js = [] + inspect_view_extra_css = [] + inspect_view_extra_js = [] + form_view_extra_css = [] + form_view_extra_js = [] + + def __init__(self, parent=None): + """ + Don't allow initialisation unless self.model is set to a valid model + """ + if not self.model or not issubclass(self.model, Model): + raise ImproperlyConfigured( + u"The model attribute on your '%s' class must be set, and " + "must be a valid Django model." % self.__class__.__name__) + self.opts = self.model._meta + self.is_pagemodel = issubclass(self.model, Page) + self.parent = parent + self.permission_helper = self.get_permission_helper_class()( + self.model, self.inspect_view_enabled) + self.url_helper = self.get_url_helper_class()(self.model) + + + def get_permission_helper_class(self): + """ + Returns a permission_helper class to help with permission-based logic + for the given model. + """ + if self.permission_helper_class: + return self.permission_helper_class + if self.is_pagemodel: + return PagePermissionHelper + return PermissionHelper + + def get_url_helper_class(self): + if self.url_helper_class: + return self.url_helper_class + if self.is_pagemodel: + return PageAdminURLHelper + return AdminURLHelper + + def get_button_helper_class(self): + """ + Returns a ButtonHelper class to help generate buttons for the given + model. + """ + if self.button_helper_class: + return self.button_helper_class + if self.is_pagemodel: + return PageButtonHelper + return ButtonHelper + + def get_menu_label(self): + """ + Returns the label text to be used for the menu item. + """ + return self.menu_label or self.opts.verbose_name_plural.title() + + def get_menu_icon(self): + """ + Returns the icon to be used for the menu item. The value is prepended + with 'icon-' to create the full icon class name. For design + consistency, the same icon is also applied to the main heading for + views called by this class. + """ + if self.menu_icon: + return self.menu_icon + if self.is_pagemodel: + return 'doc-full-inverse' + return 'snippet' + + def get_menu_order(self): + """ + Returns the 'order' to be applied to the menu item. 000 being first + place. Where ModelAdminGroup is used, the menu_order value should be + applied to that, and any ModelAdmin classes added to 'items' + attribute will be ordered automatically, based on their order in that + sequence. + """ + return self.menu_order or 999 + + def get_list_display(self, request): + """ + Return a sequence containing the fields/method output to be displayed + in the list view. + """ + return self.list_display + + def get_list_display_add_buttons(self, request): + """ + Return the name of the field/method from list_display where action + buttons should be added. Defaults to the first item from + get_list_display() + """ + return self.list_display_add_buttons or self.get_list_display( + request)[0] + + def get_empty_value_display(self, field_name=None): + """ + Return the empty_value_display value defined on ModelAdmin + """ + return mark_safe(self.empty_value_display) + + def get_list_filter(self, request): + """ + Returns a sequence containing the fields to be displayed as filters in + the right sidebar in the list view. + """ + return self.list_filter + + def get_ordering(self, request): + """ + Returns a sequence defining the default ordering for results in the + list view. + """ + return self.ordering or () + + def get_queryset(self, request): + """ + Returns a QuerySet of all model instances that can be edited by the + admin site. + """ + qs = self.model._default_manager.get_queryset() + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + + def get_search_fields(self, request): + """ + Returns a sequence defining which fields on a model should be searched + when a search is initiated from the list view. + """ + return self.search_fields or () + + def get_extra_class_names_for_field_col(self, obj, field_name): + """ + Return a list of additional CSS class names to be added to the table + cell's `class` attribute when rendering the output of `field_name` for + `obj` in `index_view`. + + Must always return a list. + """ + return [] + + def get_extra_attrs_for_field_col(self, obj, field_name): + """ + Return a dictionary of additional HTML attributes to be added to a + table cell when rendering the output of `field_name` for `obj` in + `index_view`. + + Must always return a dictionary. + """ + return {} + + def get_index_view_extra_css(self): + css = ['wagtailmodeladmin/css/index.css'] + css.extend(self.index_view_extra_css) + return css + + def get_index_view_extra_js(self): + return self.index_view_extra_js + + def get_form_view_extra_css(self): + return self.form_view_extra_css + + def get_form_view_extra_js(self): + return self.form_view_extra_js + + def get_inspect_view_extra_css(self): + return self.inspect_view_extra_css + + def get_inspect_view_extra_js(self): + return self.inspect_view_extra_js + + def get_inspect_view_fields(self): + """ + Return a list of field names, indicating the model fields that + should be displayed in the 'inspect' view. Returns the value of the + 'inspect_view_fields' attribute if populated, otherwise a sensible + list of fields is generated automatically, with any field named in + 'inspect_view_fields_exclude' not being included. + """ + if not self.inspect_view_fields: + found_fields = [] + for f in self.model._meta.get_fields(): + if f.name not in self.inspect_view_fields_exclude: + if f.concrete and ( + not f.is_relation or + (not f.auto_created and f.related_model) + ): + found_fields.append(f.name) + return found_fields + return self.inspect_view_fields + + def index_view(self, request): + """ + Instantiates a class-based view to provide listing functionality for + the assigned model. The view class used can be overridden by changing + the 'index_view_class' attribute. + """ + kwargs = {'model_admin': self} + view_class = self.index_view_class + return view_class.as_view(**kwargs)(request) + + def create_view(self, request): + """ + Instantiates a class-based view to provide 'creation' functionality for + the assigned model, or redirect to Wagtail's create view if the + assigned model extends 'Page'. The view class used can be overridden by + changing the 'create_view_class' attribute. + """ + kwargs = {'model_admin': self} + view_class = self.create_view_class + return view_class.as_view(**kwargs)(request) + + def choose_parent_view(self, request): + """ + Instantiates a class-based view to allows a parent page to be chosen + for a new object, where the assigned model extends Wagtail's Page + model, and there is more than one potential parent for new instances. + The view class used can be overridden by changing the + 'choose_parent_view_class' attribute. + """ + kwargs = {'model_admin': self} + view_class = self.choose_parent_view_class + return view_class.as_view(**kwargs)(request) + + def inspect_view(self, request, instance_pk): + """ + Instantiates a class-based view to provide 'inspect' functionality for + the assigned model. The view class used can be overridden by changing + the 'inspect_view_class' attribute. + """ + kwargs = {'model_admin': self, 'instance_pk': instance_pk} + view_class = self.inspect_view_class + return view_class.as_view(**kwargs)(request) + + def edit_view(self, request, instance_pk): + """ + Instantiates a class-based view to provide 'edit' functionality for the + assigned model, or redirect to Wagtail's edit view if the assinged + model extends 'Page'. The view class used can be overridden by changing + the 'edit_view_class' attribute. + """ + kwargs = {'model_admin': self, 'instance_pk': instance_pk} + view_class = self.edit_view_class + return view_class.as_view(**kwargs)(request) + + def delete_view(self, request, instance_pk): + """ + Instantiates a class-based view to provide 'delete confirmation' + functionality for the assigned model, or redirect to Wagtail's delete + confirmation view if the assinged model extends 'Page'. The view class + used can be overridden by changing the 'delete_view_class' + attribute. + """ + kwargs = {'model_admin': self, 'instance_pk': instance_pk} + view_class = self.delete_view_class + return view_class.as_view(**kwargs)(request) + + def get_templates(self, action='index'): + """ + Utility funtion that provides a list of templates to try for a given + view, when the template isn't overridden by one of the template + attributes on the class. + """ + app_label = self.opts.app_label.lower() + model_name = self.opts.model_name.lower() + return [ + 'modeladmin/%s/%s/%s.html' % (app_label, model_name, action), + 'modeladmin/%s/%s.html' % (app_label, action), + 'modeladmin/%s.html' % (action,), + ] + + def get_index_template(self): + """ + Returns a template to be used when rendering 'index_view'. If a + template is specified by the 'index_template_name' attribute, that will + be used. Otherwise, a list of preferred template names are returned. + """ + return self.index_template_name or self.get_templates('index') + + def get_choose_parent_template(self): + """ + Returns a template to be used when rendering 'choose_parent_view'. If a + template is specified by the 'choose_parent_template_name' attribute, + that will be used. Otherwise, a list of preferred template names are + returned. + """ + return self.choose_parent_template_name or self.get_templates( + 'choose_parent') + + def get_inspect_template(self): + """ + Returns a template to be used when rendering 'inspect_view'. If a + template is specified by the 'inspect_template_name' attribute, that + will be used. Otherwise, a list of preferred template names are + returned. + """ + return self.inspect_template_name or self.get_templates('inspect') + + def get_create_template(self): + """ + Returns a template to be used when rendering 'create_view'. If a + template is specified by the 'create_template_name' attribute, + that will be used. Otherwise, a list of preferred template names are + returned. + """ + return self.create_template_name or self.get_templates('create') + + def get_edit_template(self): + """ + Returns a template to be used when rendering 'edit_view'. If a template + is specified by the 'edit_template_name' attribute, that will be used. + Otherwise, a list of preferred template names are returned. + """ + return self.edit_template_name or self.get_templates('edit') + + def get_delete_template(self): + """ + Returns a template to be used when rendering 'delete_view'. If + a template is specified by the 'delete_template_name' + attribute, that will be used. Otherwise, a list of preferred template + names are returned. + """ + return self.delete_template_name or self.get_templates('delete') + + def get_menu_item(self, order=None): + """ + Utilised by Wagtail's 'register_menu_item' hook to create a menu item + to access the listing view, or can be called by ModelAdminGroup + to create a SubMenu + """ + return ModelAdminMenuItem(self, order or self.get_menu_order()) + + def get_permissions_for_registration(self): + """ + Utilised by Wagtail's 'register_permissions' hook to allow permissions + for a model to be assigned to groups in settings. This is only required + if the model isn't a Page model, and isn't registered as a Snippet + """ + from wagtail.wagtailsnippets.models import SNIPPET_MODELS + if not self.is_pagemodel and self.model not in SNIPPET_MODELS: + return self.permission_helper.get_all_model_permissions() + return Permission.objects.none() + + def get_admin_urls_for_registration(self): + """ + Utilised by Wagtail's 'register_admin_urls' hook to register urls for + our the views that class offers. + """ + urls = ( + url(self.url_helper.get_action_url_pattern('index'), + self.index_view, + name=self.url_helper.get_action_url_name('index')), + url(self.url_helper.get_action_url_pattern('create'), + self.create_view, + name=self.url_helper.get_action_url_name('create')), + url(self.url_helper.get_action_url_pattern('edit'), + self.edit_view, + name=self.url_helper.get_action_url_name('edit')), + url(self.url_helper.get_action_url_pattern('delete'), + self.delete_view, + name=self.url_helper.get_action_url_name('delete')), + ) + if self.inspect_view_enabled: + urls = urls + ( + url(self.url_helper.get_action_url_pattern('inspect'), + self.inspect_view, + name=self.url_helper.get_action_url_name('inspect')), + ) + if self.is_pagemodel: + urls = urls + ( + url(self.url_helper.get_action_url_pattern('choose_parent'), + self.choose_parent_view, + name=self.url_helper.get_action_url_name('choose_parent')), + ) + return urls + + +class ModelAdminGroup(WagtailRegisterable): + """ + Acts as a container for grouping together mutltiple PageModelAdmin and + SnippetModelAdmin instances. Creates a menu item with a SubMenu for + accessing the listing pages of those instances + """ + items = () + menu_label = None + menu_order = None + menu_icon = None + + def __init__(self): + """ + When initialising, instantiate the classes within 'items', and assign + the instances to a 'modeladmin_instances' attribute for convienient + access later + """ + self.modeladmin_instances = [] + for ModelAdminClass in self.items: + self.modeladmin_instances.append(ModelAdminClass(parent=self)) + + def get_menu_label(self): + return self.menu_label or self.get_app_label_from_subitems() + + def get_app_label_from_subitems(self): + for instance in self.modeladmin_instances: + return instance.opts.app_label.title() + return '' + + def get_menu_icon(self): + return self.menu_icon or 'icon-folder-open-inverse' + + def get_menu_order(self): + return self.menu_order or 999 + + def get_menu_item(self): + """ + Utilised by Wagtail's 'register_menu_item' hook to create a menu + for this group with a SubMenu linking to listing pages for any + associated ModelAdmin instances + """ + if self.modeladmin_instances: + submenu = SubMenu(self.get_submenu_items()) + return GroupMenuItem(self, self.get_menu_order(), submenu) + + def get_submenu_items(self): + menu_items = [] + item_order = 1 + for modeladmin in self.modeladmin_instances: + menu_items.append(modeladmin.get_menu_item(order=item_order)) + item_order += 1 + return menu_items + + def get_permissions_for_registration(self): + """ + Utilised by Wagtail's 'register_permissions' hook to allow permissions + for a all models grouped by this class to be assigned to Groups in + settings. + """ + qs = Permission.objects.none() + for instance in self.modeladmin_instances: + qs = qs | instance.get_permissions_for_registration() + return qs + + def get_admin_urls_for_registration(self): + """ + Utilised by Wagtail's 'register_admin_urls' hook to register urls for + used by any associated ModelAdmin instances + """ + urls = tuple() + for instance in self.modeladmin_instances: + urls += instance.get_admin_urls_for_registration() + return urls + + +def modeladmin_register(modeladmin_class): + """ + Method for registering ModelAdmin or ModelAdminGroup classes with Wagtail. + """ + instance = modeladmin_class() + instance.register_with_wagtail() diff --git a/wagtail/contrib/modeladmin/static_src/modeladmin/scss/choose_parent_page.scss b/wagtail/contrib/modeladmin/static_src/modeladmin/scss/choose_parent_page.scss new file mode 100644 index 000000000..dc018a91e --- /dev/null +++ b/wagtail/contrib/modeladmin/static_src/modeladmin/scss/choose_parent_page.scss @@ -0,0 +1,6 @@ +#id_parent_page li { + margin: 15px 0; +} +#id_parent_page li label { + float: none; +} diff --git a/wagtail/contrib/modeladmin/static_src/modeladmin/scss/index.scss b/wagtail/contrib/modeladmin/static_src/modeladmin/scss/index.scss new file mode 100644 index 000000000..25c0cb4a9 --- /dev/null +++ b/wagtail/contrib/modeladmin/static_src/modeladmin/scss/index.scss @@ -0,0 +1,147 @@ +.content header { margin-bottom: 0; } +#result_list { + padding: 0 15px; +} +#result_list table { margin-bottom:0; } +#result_list tbody th { + background-color: transparent; + text-align: left; + padding: 1.2em 1em; +} +#result_list tbody tr:hover ul.actions { + visibility: visible; +} +#result_list tbody td, #result_list tbody th { + vertical-align: top; +} + +#changelist-filter { + padding: 0 15px; +} + +#changelist-filter h2 { + background-color: #fafafa; + font-size: 13px; + line-height: 31px; + margin-top: 0; + padding-left: 8px; + border-bottom: 1px solid #E6E6E6; +} + +#changelist-filter h3 { + font-size: 12px; + margin-bottom: 0; +} + +#changelist-filter ul { + padding-left: 0; + margin-bottom: 25px; +} + +#changelist-filter li { + list-style-type: none; + margin: 0 0 4px; + padding-left: 0; +} + +#changelist-filter a { + font-family: Open Sans,Arial,sans-serif; + border-radius: 3px; + width: auto; + line-height: 1.2em; + padding: 8px 12px; + font-size: 0.9em; + font-weight: normal; + vertical-align: middle; + display: block; + background-color: white; + border: 1px solid #43b1b0; + color: #43b1b0; + text-decoration: none; + text-transform: uppercase; + position: relative; + overflow: hidden; + outline: none; + box-sizing: border-box; + -webkit-font-smoothing: auto; + -moz-appearance: none; + -moz-box-sizing: border-box; +} + +#changelist-filter a:hover { + background-color: #358c8b; + border-color: #358c8b; + color: white; + +} + +#changelist-filter li.selected a { + color: white !important; + border-color: #43b1b0 !important; + background-color: #43b1b0; +} + +.no-search-results { + margin-top: 30px; +} + +.no-search-results h2 { + padding-top: 0.3em; + margin-bottom: 0.3em; +} + +.no-search-results img { + float: left; + margin: 0 15px 15px 0; + width: 50px; +} + +div.pagination { + margin-top: 3em; + border-top: 1px dashed #d9d9d9; + padding: 2em 1em 0; +} +div.pagination ul { + margin-top: -1.25em +} +p.no-results { + margin: 30px 1em 0; +} + +@media screen and (min-width: 50em) { + #changelist-filter { + float: right; + padding: 0 1.5%; + } + #result_list { + padding: 0 1.5% 0 0; + } + #result_list tbody th:first-child { + padding-left: 50px; + } + #result_list.col12 tbody td:last-child { + padding-right: 50px; + } + div.pagination { + padding-left: 50px; + padding-right: 50px; + } + div.pagination.col9 { + width: 73.5%; + } + p.no-results { + margin: 30px 50px 0; + } +} + +@media screen and (min-width: 1200px) { + #result_list.col9 { + width: 79%; + } + #changelist-filter { + width: 21%; + } + div.pagination.col9 { + width: 77.5%; + } +} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/choose_parent.html b/wagtail/contrib/modeladmin/templates/modeladmin/choose_parent.html new file mode 100644 index 000000000..b67e62fc6 --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/choose_parent.html @@ -0,0 +1,43 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n admin_static %} + +{% block titletag %}{{ view.get_meta_title }}{% endblock %} + +{% block extra_css %} + {% include "wagtailadmin/pages/_editor_css.html" %} + +{% endblock %} + +{% block extra_js %} + {% include "wagtailadmin/pages/_editor_js.html" %} +{% endblock %} + + +{% block content %} +
+ + {% block header %} + {% include "modeladmin/includes/breadcrumb.html" %} + {% include "wagtailadmin/shared/header.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon %} + {% endblock %} + +
+

{% blocktrans %}Choose a parent page{% endblocktrans %}

+

{% blocktrans with view.verbose_name_plural|capfirst as plural %}{{ plural }} can be added to more than one place within your site. Which of the following would you like to be the parent of your new page?{% endblocktrans %}

+ +
+ {% csrf_token %} + +
    + {% include "wagtailadmin/shared/field_as_li.html" with field=form.parent_page %} +
  • + +
  • +
+
+ +
+
+{% endblock %} + + diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/create.html b/wagtail/contrib/modeladmin/templates/modeladmin/create.html new file mode 100644 index 000000000..7b94a8b2e --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/create.html @@ -0,0 +1,41 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n %} + +{% block titletag %}{{ view.get_meta_title }}{% endblock %} + +{% block extra_css %} + {% include "wagtailadmin/pages/_editor_css.html" %} + {{ view.media.css }} +{% endblock %} + +{% block extra_js %} + {% include "wagtailadmin/pages/_editor_js.html" %} + {{ view.media.js }} +{% endblock %} + +{% block content %} + + {% block header %} + {% include "wagtailadmin/shared/header.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon tabbed=1 merged=1 %} + {% endblock %} + +
+ {% csrf_token %} + + {% block form %}{{ edit_handler.render_form_content }}{% endblock %} + + {% block footer %} +
+
    +
  • + {% block form_actions %} + + {% endblock %} +
  • +
+
+ {% endblock %} +
+{% endblock %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/delete.html b/wagtail/contrib/modeladmin/templates/modeladmin/delete.html new file mode 100644 index 000000000..7afcb72dc --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/delete.html @@ -0,0 +1,30 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n modeladmin_tags %} + +{% block titletag %}{{ view.get_meta_title }}{% endblock %} + +{% block content %} + + {% block header %} + {% include "wagtailadmin/shared/header.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon %} + {% endblock %} + + {% block content_main %} +
+ {% if protected_error %} +

{% blocktrans with view.verbose_name|capfirst as model_name %}{{ model_name }} could not be deleted{% endblocktrans %}

+

{% blocktrans with instance as instance_name %}'{{ instance_name }}' is currently referenced by other objects, and cannot be deleted without jeopardising data integrity. To delete it successfully, first remove references from the following objects, then try again:{% endblocktrans %}

+
    + {% for obj in linked_objects %}
  • {{ obj|get_content_type_for_obj|title }}: {{ obj }}
  • {% endfor %} +
+

{% trans 'Go back to listing' %}

+ {% else %} +

{{ view.confirmation_message }}

+
+ {% csrf_token %} + +
+ {% endif %} +
+ {% endblock %} +{% endblock %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/edit.html b/wagtail/contrib/modeladmin/templates/modeladmin/edit.html new file mode 100644 index 000000000..18d08fa53 --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/edit.html @@ -0,0 +1,18 @@ +{% extends "modeladmin/create.html" %} +{% load i18n %} + +{% block form_action %}{{ view.edit_url }}{% endblock %} + +{% block form_actions %} + +{% endblock %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/breadcrumb.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/breadcrumb.html new file mode 100644 index 000000000..5368accb0 --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/breadcrumb.html @@ -0,0 +1,5 @@ +{% load i18n %} + diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/button.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/button.html new file mode 100644 index 000000000..eabc958cd --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/button.html @@ -0,0 +1 @@ +{{ button.label }} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/filter.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/filter.html new file mode 100644 index 000000000..132d2f7a9 --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/filter.html @@ -0,0 +1,8 @@ +{% load i18n %} +{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %} + diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_list.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_list.html new file mode 100644 index 000000000..af1b4313e --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_list.html @@ -0,0 +1,28 @@ +{% load i18n modeladmin_tags %} +{% if results %} + + + + {% for header in result_headers %} + + {% endfor %} + + + + {% for result in results %} + + {% result_row_display forloop.counter0 %} + + {% endfor %} + +
+ {% if header.sortable %}{% endif %} + {{ header.text|capfirst }} + {% if header.sortable %}{% endif %} +
+{% else %} +
+

{% blocktrans with view.verbose_name_plural as name %}Sorry, there are no {{ name }} matching your search parameters.{% endblocktrans %}

+
+{% endif %} + diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row.html new file mode 100644 index 000000000..b33ff908f --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row.html @@ -0,0 +1,4 @@ +{% load modeladmin_tags %} +{% for item in result %} + {% result_row_value_display forloop.counter0 %} +{% endfor %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row_value.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row_value.html new file mode 100644 index 000000000..3202168ff --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row_value.html @@ -0,0 +1,11 @@ +{% load i18n modeladmin_tags %} +{{ item }}{% if add_action_buttons %} + {% if action_buttons %} +
    + {% for button in action_buttons %} +
  • {% include 'modeladmin/includes/button.html' %}
  • + {% endfor %} +
+ {% endif %} + {{ item_closing_tag }} +{% endif %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/includes/search_form.html b/wagtail/contrib/modeladmin/templates/modeladmin/includes/search_form.html new file mode 100644 index 000000000..3bb387323 --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/includes/search_form.html @@ -0,0 +1,20 @@ +{% load i18n admin_static %} +{% if view.search_fields %} + + +{% endif %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/index.html b/wagtail/contrib/modeladmin/templates/modeladmin/index.html new file mode 100644 index 000000000..bd8bacc9b --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/index.html @@ -0,0 +1,93 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n modeladmin_tags %} + +{% block titletag %}{{ view.get_meta_title }}{% endblock %} + +{% block css %} + {{ block.super }} + {{ view.media.css }} +{% endblock %} + +{% block extra_js %} + {{ view.media.js }} +{% endblock %} + +{% block content %} + {% block header %} +
+
+
+
+ {% block h1 %}

{{ view.get_page_title }}

{% endblock %} +
+ {% block search %}{% search_form %}{% endblock %} +
+ {% block header_extra %} + {% if user_can_create %} +
+
+ {% include 'modeladmin/includes/button.html' with button=view.button_helper.add_button %} +
+
+ {% endif %} + {% endblock %} +
+
+ {% endblock %} + + {% block content_main %} +
+
+ {% block content_cols %} + + {% block filters %} + {% if view.has_filters and all_count %} +
+

{% trans 'Filter' %}

+ {% for spec in view.filter_specs %}{% admin_list_filter view spec %}{% endfor %} +
+ {% endif %} + {% endblock %} + +
+ {% block result_list %} + {% if not all_count %} +
+ {% if no_valid_parents %} +

{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet. One of the following must be created before you can add any {{ name }}:{% endblocktrans %}

+
    + {% for type in required_parent_types %}
  • {{ type|title }}
  • {% endfor %} +
+ {% else %} +

{% blocktrans with view.verbose_name_plural as name %}No {{ name }} have been created yet.{% endblocktrans %} + {% if user_can_create %} + {% blocktrans with view.create_url as url %} + Why not add one? + {% endblocktrans %} + {% endif %}

+ {% endif %} +
+ {% else %} + {% result_list %} + {% endif %} + {% endblock %} +
+ + {% block pagination %} + + {% endblock %} + + {% endblock %} +
+
+ {% endblock %} + +{% endblock %} diff --git a/wagtail/contrib/modeladmin/templates/modeladmin/inspect.html b/wagtail/contrib/modeladmin/templates/modeladmin/inspect.html new file mode 100644 index 000000000..80540409e --- /dev/null +++ b/wagtail/contrib/modeladmin/templates/modeladmin/inspect.html @@ -0,0 +1,55 @@ +{% extends "wagtailadmin/base.html" %} +{% load i18n %} + +{% block titletag %}{{ view.get_meta_title }}{% endblock %} + +{% block extra_css %} + {{ view.media.css }} +{% endblock %} + +{% block extra_js %} + {{ view.media.js }} +{% endblock %} + +{% block content %} +
+ + {% block header %} + {% include "modeladmin/includes/breadcrumb.html" %} + {% include "wagtailadmin/shared/header.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon %} + {% endblock %} + + {% block content_main %} +
+ +

{% blocktrans with view.verbose_name as model_name %}Back to {{ model_name }} list{% endblocktrans %}

+ + {% block fields_output %} + {% if fields %} +
+ {% for field in fields %} +
{{ field.label }}
+
{{ field.value }}
+ {% endfor %} +
+ {% endif %} + {% endblock %} +
+ {% endblock %} + +
+ + {% block footer %} + {% if buttons %} +
+
+ {% for button in buttons %} + {% include "modeladmin/includes/button.html" %} + {% endfor %} +
+
+ {% endif %} + {% endblock %} +{% endblock %} + + diff --git a/wagtail/contrib/modeladmin/templatetags/__init__.py b/wagtail/contrib/modeladmin/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py b/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py new file mode 100644 index 000000000..0cec8b3f5 --- /dev/null +++ b/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py @@ -0,0 +1,188 @@ +from __future__ import absolute_import, unicode_literals + +import datetime + +import django +from django.contrib.admin.templatetags.admin_list import ResultList, result_headers +from django.contrib.admin.utils import display_for_field, display_for_value, lookup_field +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.template import Library +from django.template.loader import get_template +from django.utils.encoding import force_text +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ + +register = Library() + + +def items_for_result(view, result): + """ + Generates the actual list of data. + """ + modeladmin = view.model_admin + for field_name in view.list_display: + empty_value_display = modeladmin.get_empty_value_display(field_name) + row_classes = ['field-%s' % field_name] + try: + f, attr, value = lookup_field(field_name, result, modeladmin) + except ObjectDoesNotExist: + result_repr = empty_value_display + else: + empty_value_display = getattr( + attr, 'empty_value_display', empty_value_display) + if f is None or f.auto_created: + allow_tags = getattr(attr, 'allow_tags', False) + boolean = getattr(attr, 'boolean', False) + if boolean or not value: + allow_tags = True + if django.VERSION >= (1, 9): + result_repr = display_for_value( + value, empty_value_display, boolean) + else: + result_repr = display_for_value(value, boolean) + + # Strip HTML tags in the resulting text, except if the + # function has an "allow_tags" attribute set to True. + if allow_tags: + result_repr = mark_safe(result_repr) + if isinstance(value, (datetime.date, datetime.time)): + row_classes.append('nowrap') + else: + if isinstance(f, models.ManyToOneRel): + field_val = getattr(result, f.name) + if field_val is None: + result_repr = empty_value_display + else: + result_repr = field_val + else: + if django.VERSION >= (1, 9): + result_repr = display_for_field( + value, f, empty_value_display) + else: + result_repr = display_for_field(value, f) + + if isinstance(f, ( + models.DateField, models.TimeField, models.ForeignKey) + ): + row_classes.append('nowrap') + if force_text(result_repr) == '': + result_repr = mark_safe(' ') + row_classes.extend( + modeladmin.get_extra_class_names_for_field_col(field_name, result)) + row_attrs_dict = modeladmin.get_extra_attrs_for_field_col( + field_name, result) + row_attrs_dict['class'] = ' ' . join(row_classes) + row_attrs = ''.join( + ' %s="%s"' % (key, val) for key, val in row_attrs_dict.items()) + row_attrs_safe = mark_safe(row_attrs) + yield format_html('{}', row_attrs_safe, result_repr) + + +def results(view, object_list): + for item in object_list: + yield ResultList(None, items_for_result(view, item)) + + +@register.inclusion_tag("modeladmin/includes/result_list.html", + takes_context=True) +def result_list(context): + """ + Displays the headers and data list together + """ + view = context['view'] + object_list = context['object_list'] + headers = list(result_headers(view)) + num_sorted_fields = 0 + for h in headers: + if h['sortable'] and h['sorted']: + num_sorted_fields += 1 + context.update({ + 'result_headers': headers, + 'num_sorted_fields': num_sorted_fields, + 'results': list(results(view, object_list))}) + return context + + +@register.simple_tag +def pagination_link_previous(current_page, view): + if current_page.has_previous(): + previous_page_number0 = current_page.previous_page_number() - 1 + return format_html( + '' % + (view.get_query_string({view.PAGE_VAR: previous_page_number0}), + _('Previous')) + ) + return '' + + +@register.simple_tag +def pagination_link_next(current_page, view): + if current_page.has_next(): + next_page_number0 = current_page.next_page_number() - 1 + return format_html( + '' % + (view.get_query_string({view.PAGE_VAR: next_page_number0}), + _('Next')) + ) + return '' + + +@register.inclusion_tag( + "modeladmin/includes/search_form.html", takes_context=True) +def search_form(context): + context.update({'search_var': context['view'].SEARCH_VAR}) + return context + + +@register.simple_tag +def admin_list_filter(view, spec): + template_name = spec.template + if template_name == 'admin/filter.html': + template_name = 'modeladmin/includes/filter.html' + tpl = get_template(template_name) + return tpl.render({ + 'title': spec.title, + 'choices': list(spec.choices(view)), + 'spec': spec, + }) + + +@register.inclusion_tag( + "modeladmin/includes/result_row.html", takes_context=True) +def result_row_display(context, index): + obj = context['object_list'][index] + view = context['view'] + context.update({ + 'obj': obj, + 'action_buttons': view.get_buttons_for_obj(obj), + }) + return context + + +@register.inclusion_tag( + "modeladmin/includes/result_row_value.html", takes_context=True) +def result_row_value_display(context, index): + add_action_buttons = False + item = context['item'] + closing_tag = mark_safe(item[-5:]) + request = context['request'] + model_admin = context['view'].model_admin + field_name = model_admin.get_list_display(request)[index] + if field_name == model_admin.get_list_display_add_buttons(request): + add_action_buttons = True + item = mark_safe(item[0:-5]) + context.update({ + 'item': item, + 'add_action_buttons': add_action_buttons, + 'closing_tag': closing_tag, + }) + return context + + +@register.filter +def get_content_type_for_obj(obj): + return obj.__class__._meta.verbose_name diff --git a/wagtail/contrib/modeladmin/tests/__init__.py b/wagtail/contrib/modeladmin/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/contrib/modeladmin/tests/test_page_modeladmin.py b/wagtail/contrib/modeladmin/tests/test_page_modeladmin.py new file mode 100644 index 000000000..c10e44342 --- /dev/null +++ b/wagtail/contrib/modeladmin/tests/test_page_modeladmin.py @@ -0,0 +1,221 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase + +from wagtail.wagtailcore.models import Page +from wagtail.tests.testapp.models import BusinessIndex +from wagtail.tests.utils import WagtailTestUtils + + +class TestIndexView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def get(self, **params): + return self.client.get('/admin/tests/eventpage/', params) + + def test_simple(self): + response = self.get() + + self.assertEqual(response.status_code, 200) + + # There are four event pages in the test data + self.assertEqual(response.context['result_count'], 4) + + # User has add permission + self.assertEqual(response.context['user_can_create'], True) + + def test_filter(self): + # Filter by audience + response = self.get(audience__exact='public') + + self.assertEqual(response.status_code, 200) + + # Only three of the event page in the test data are 'public' + self.assertEqual(response.context['result_count'], 3) + + for eventpage in response.context['object_list']: + self.assertEqual(eventpage.audience, 'public') + + def test_search(self): + response = self.get(q='Someone') + + self.assertEqual(response.status_code, 200) + + # There are two eventpage's where the title contains 'Someone' + self.assertEqual(response.context['result_count'], 1) + + def test_ordering(self): + response = self.get(o='0.1') + + self.assertEqual(response.status_code, 200) + + # There should still be four results + self.assertEqual(response.context['result_count'], 4) + + +class TestCreateView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def test_redirect_to_choose_parent(self): + # When more than one possible parent page exists, redirect to choose_parent + response = self.client.get('/admin/tests/eventpage/create/') + self.assertRedirects(response, '/admin/tests/eventpage/choose_parent/') + + def test_one_parent_exists(self): + # Create a BusinessIndex page that BusinessChild can exist under + homepage = Page.objects.get(url_path='/home/') + business_index = BusinessIndex(title='Business Index') + homepage.add_child(instance=business_index) + + # When one possible parent page exists, redirect straight to the page create view + response = self.client.get('/admin/tests/businesschild/create/') + + expected_path = '/admin/pages/add/tests/businesschild/%d/' % business_index.pk + expected_next_path = '/admin/tests/businesschild/' + self.assertRedirects(response, '%s?next=%s' % (expected_path, expected_next_path)) + + +class TestInspectView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def get(self, id): + return self.client.get('/admin/tests/eventpage/inspect/%d/' % id) + + def test_simple(self): + response = self.get(4) + self.assertEqual(response.status_code, 200) + + def test_title_present(self): + """ + The page title should appear twice. Once in the header, and once + more in the field listing + """ + response = self.get(4) + self.assertContains(response, 'Christmas', 2) + + def test_location_present(self): + """ + The location should appear once, in the field listing + """ + response = self.get(4) + self.assertContains(response, 'The North Pole', 1) + + def test_non_existent(self): + response = self.get(100) + self.assertEqual(response.status_code, 404) + + +class TestEditView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def get(self, obj_id): + return self.client.get('/admin/tests/eventpage/edit/%d/' % obj_id) + + def test_simple(self): + response = self.get(4) + + expected_path = '/admin/pages/4/edit/' + expected_next_path = '/admin/tests/eventpage/' + self.assertRedirects(response, '%s?next=%s' % (expected_path, expected_next_path)) + + def test_non_existent(self): + response = self.get(100) + + self.assertEqual(response.status_code, 404) + + +class TestDeleteView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def get(self, obj_id): + return self.client.get('/admin/tests/eventpage/delete/%d/' % obj_id) + + def test_simple(self): + response = self.get(4) + + expected_path = '/admin/pages/4/delete/' + expected_next_path = '/admin/tests/eventpage/' + self.assertRedirects(response, '%s?next=%s' % (expected_path, expected_next_path)) + + +class TestChooseParentView(TestCase, WagtailTestUtils): + fixtures = ['test_specific.json'] + + def setUp(self): + self.login() + + def test_simple(self): + response = self.client.get('/admin/tests/eventpage/choose_parent/') + + self.assertEqual(response.status_code, 200) + + def test_no_parent_exists(self): + response = self.client.get('/admin/tests/businesschild/choose_parent/') + + self.assertEqual(response.status_code, 403) + + def test_post(self): + response = self.client.post('/admin/tests/eventpage/choose_parent/', { + 'parent_page': 2, + }) + + expected_path = '/admin/pages/add/tests/eventpage/2/' + expected_next_path = '/admin/tests/eventpage/' + self.assertRedirects(response, '%s?next=%s' % (expected_path, expected_next_path)) + + +class TestEditorAccess(TestCase): + fixtures = ['test_specific.json'] + expected_status_code = 403 + + def login(self): + # Create a user + user = get_user_model().objects._create_user(username='test2', email='test2@email.com', password='password', is_staff=True, is_superuser=False) + user.groups.add(Group.objects.get(pk=2)) + # Login + self.client.login(username='test2', password='password') + return user + + def setUp(self): + self.login() + + def test_delete_permitted(self): + response = self.client.get('/admin/tests/eventpage/delete/4/') + self.assertEqual(response.status_code, self.expected_status_code) + + +class TestModeratorAccess(TestCase): + fixtures = ['test_specific.json'] + expected_status_code = 302 + + def login(self): + # Create a user + user = get_user_model().objects._create_user(username='test3', email='test3@email.com', password='password', is_staff=True, is_superuser=False) + user.groups.add(Group.objects.get(pk=1)) + # Login + self.client.login(username='test2', password='password') + return user + + def setUp(self): + self.login() + + def test_delete_permitted(self): + response = self.client.get('/admin/tests/eventpage/delete/4/') + self.assertEqual(response.status_code, self.expected_status_code) diff --git a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py new file mode 100644 index 000000000..11d5be0ce --- /dev/null +++ b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py @@ -0,0 +1,357 @@ +from __future__ import absolute_import, unicode_literals + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.test import TestCase + +from wagtail.tests.modeladmintest.models import Book, Author +from wagtail.tests.utils import WagtailTestUtils + + +class TestIndexView(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get(self, **params): + return self.client.get('/admin/modeladmintest/book/', params) + + def test_simple(self): + response = self.get() + + self.assertEqual(response.status_code, 200) + + # There are four books in the test data + self.assertEqual(response.context['result_count'], 4) + + # User has add permission + self.assertEqual(response.context['user_can_create'], True) + + def test_filter(self): + # Filter by author 1 (JRR Tolkien) + response = self.get(author__id__exact=1) + + self.assertEqual(response.status_code, 200) + + # JRR Tolkien has two books in the test data + self.assertEqual(response.context['result_count'], 2) + + for book in response.context['object_list']: + self.assertEqual(book.author_id, 1) + + def test_search(self): + response = self.get(q='of') + + self.assertEqual(response.status_code, 200) + + # There are two books where the title contains 'of' + self.assertEqual(response.context['result_count'], 2) + + def test_ordering(self): + response = self.get(o='0.1') + + self.assertEqual(response.status_code, 200) + + # There are four books in the test data + self.assertEqual(response.context['result_count'], 4) + + def test_paging(self): + # should be corrected to just the first page, as there aren't enough + # objects to make up more than one page + response = self.get(p=9) + + self.assertEqual(response.status_code, 200) + + # There are four books in the test data + self.assertEqual(response.context['result_count'], 4) + + # Should raise a ValueError that gets caught during initialisation + response = self.get(p='notaninteger') + + self.assertEqual(response.status_code, 200) + + # There are four books in the test data + self.assertEqual(response.context['result_count'], 4) + + +class TestCreateView(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get(self): + return self.client.get('/admin/modeladmintest/book/create/') + + def post(self, post_data): + return self.client.post('/admin/modeladmintest/book/create/', post_data) + + def test_simple(self): + response = self.get() + + self.assertEqual(response.status_code, 200) + + def test_create(self): + response = self.post({ + 'title': "George's Marvellous Medicine", + 'author': 2, + }) + # Should redirect back to index + self.assertRedirects(response, '/admin/modeladmintest/book/') + + # Check that the book was created + self.assertEqual(Book.objects.filter(title="George's Marvellous Medicine").count(), 1) + + def test_post_invalid(self): + initial_book_count = Book.objects.count() + + response = self.post({ + 'title': '', + 'author': 2, + }) + final_book_count = Book.objects.count() + + self.assertEqual(response.status_code, 200) + # Check that the book was not created + self.assertEqual(initial_book_count, final_book_count) + + # Check that a form error was raised + self.assertFormError(response, 'form', 'title', "This field is required.") + + +class TestInspectView(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get_for_author(self, author_id): + return self.client.get('/admin/modeladmintest/author/inspect/%d/' % author_id) + + def get_for_book(self, book_id): + return self.client.get('/admin/modeladmintest/book/inspect/%d/' % book_id) + + def author_test_simple(self): + response = self.get_for_author(1) + self.assertEqual(response.status_code, 200) + + def author_test_name_present(self): + """ + The author name should appear twice. Once in the header, and once + more in the field listing + """ + response = self.get_for_author(1) + self.assertContains(response, 'J. R. R. Tolkien', 2) + + def author_test_dob_not_present(self): + """ + The date of birth shouldn't appear, because the field wasn't included + in the `inspect_view_fields` list + """ + response = self.get_for_author(1) + self.assertNotContains(response, '1892', 2) + + def book_test_simple(self): + response = self.get_for_book(1) + self.assertEqual(response.status_code, 200) + + def book_test_title_present(self): + """ + The book title should appear once only, in the header, as 'title' + was added to the `inspect_view_fields_ignore` list + """ + response = self.get_for_book(1) + self.assertContains(response, 'The Lord of the Rings', 1) + + def book_test_author_present(self): + """ + The author name should appear, because 'author' is not in + `inspect_view_fields_ignore` and should be returned by the + `get_inspect_view_fields` method. + """ + response = self.get_for_book(1) + self.assertContains(response, 'J. R. R. Tolkien', 1) + + def test_non_existent(self): + response = self.get_for_book(100) + self.assertEqual(response.status_code, 404) + + +class TestEditView(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get(self, book_id): + return self.client.get('/admin/modeladmintest/book/edit/%d/' % book_id) + + def post(self, book_id, post_data): + return self.client.post('/admin/modeladmintest/book/edit/%d/' % book_id, post_data) + + def test_simple(self): + response = self.get(1) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'The Lord of the Rings') + + def test_non_existent(self): + response = self.get(100) + + self.assertEqual(response.status_code, 404) + + def test_edit(self): + response = self.post(1, { + 'title': 'The Lady of the Rings', + 'author': 1, + }) + + # Should redirect back to index + self.assertRedirects(response, '/admin/modeladmintest/book/') + + # Check that the book was updated + self.assertEqual(Book.objects.get(id=1).title, 'The Lady of the Rings') + + def test_post_invalid(self): + response = self.post(1, { + 'title': '', + 'author': 1, + }) + + self.assertEqual(response.status_code, 200) + + # Check that the title was not updated + self.assertEqual(Book.objects.get(id=1).title, 'The Lord of the Rings') + + # Check that a form error was raised + self.assertFormError(response, 'form', 'title', "This field is required.") + + +class TestPageSpecificViews(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + expected_status_code = 404 + + def setUp(self): + self.login() + + def test_choose_parent(self): + response = self.client.get('/admin/modeladmintest/book/choose_parent/') + self.assertEqual(response.status_code, self.expected_status_code) + + +class TestConfirmDeleteView(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get(self, book_id): + return self.client.get('/admin/modeladmintest/book/delete/%d/' % book_id) + + def post(self, book_id): + return self.client.post('/admin/modeladmintest/book/delete/%d/' % book_id) + + def test_simple(self): + response = self.get(1) + + self.assertEqual(response.status_code, 200) + + def test_non_existent(self): + response = self.get(100) + + self.assertEqual(response.status_code, 404) + + def test_post(self): + response = self.post(1) + + # User redirected to index + self.assertRedirects(response, '/admin/modeladmintest/book/') + + # Book deleted + self.assertFalse(Book.objects.filter(id=1).exists()) + + +class TestDeleteViewWithProtectedRelation(TestCase, WagtailTestUtils): + fixtures = ['modeladmintest_test.json'] + + def setUp(self): + self.login() + + def get(self, author_id): + return self.client.get('/admin/modeladmintest/author/delete/%d/' % author_id) + + def post(self, author_id): + return self.client.post('/admin/modeladmintest/author/delete/%d/' % author_id) + + def test_get_with_dependent_object(self): + response = self.get(1) + + self.assertEqual(response.status_code, 200) + + def test_get_without_dependent_object(self): + response = self.get(4) + + self.assertEqual(response.status_code, 200) + + def test_post_with_dependent_object(self): + response = self.post(1) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + "'J. R. R. Tolkien' is currently referenced by other objects" + ) + + # Author not deleted + self.assertTrue(Author.objects.filter(id=1).exists()) + + def test_post_without_dependent_object(self): + response = self.post(4) + + # User redirected to index + self.assertRedirects(response, '/admin/modeladmintest/author/') + + # Author deleted + self.assertFalse(Author.objects.filter(id=4).exists()) + + +class TestEditorAccess(TestCase): + fixtures = ['modeladmintest_test.json'] + expected_status_code = 403 + + def login(self): + # Create a user + user = get_user_model().objects._create_user(username='test2', email='test2@email.com', password='password', is_staff=True, is_superuser=False) + user.groups.add(Group.objects.get(pk=2)) + # Login + self.client.login(username='test2', password='password') + + return user + + def setUp(self): + self.login() + + def test_index_permitted(self): + response = self.client.get('/admin/modeladmintest/book/') + self.assertEqual(response.status_code, self.expected_status_code) + + def test_inpspect_permitted(self): + response = self.client.get('/admin/modeladmintest/book/inspect/2/') + self.assertEqual(response.status_code, self.expected_status_code) + + def test_create_permitted(self): + response = self.client.get('/admin/modeladmintest/book/create/') + self.assertEqual(response.status_code, self.expected_status_code) + + def test_edit_permitted(self): + response = self.client.get('/admin/modeladmintest/book/edit/2/') + self.assertEqual(response.status_code, self.expected_status_code) + + def test_delete_get_permitted(self): + response = self.client.get('/admin/modeladmintest/book/delete/2/') + self.assertEqual(response.status_code, self.expected_status_code) + + def test_delete_post_permitted(self): + response = self.client.post('/admin/modeladmintest/book/delete/2/') + self.assertEqual(response.status_code, self.expected_status_code) diff --git a/wagtail/contrib/modeladmin/views.py b/wagtail/contrib/modeladmin/views.py new file mode 100644 index 000000000..ca6f7fc40 --- /dev/null +++ b/wagtail/contrib/modeladmin/views.py @@ -0,0 +1,932 @@ +from __future__ import absolute_import, unicode_literals + +import operator +import sys +from collections import OrderedDict +from functools import reduce + +from django import forms +from django.contrib.admin import FieldListFilter, widgets +from django.contrib.admin.exceptions import DisallowedModelAdminLookup +from django.contrib.admin.options import IncorrectLookupParameters +from django.contrib.admin.utils import ( + get_fields_from_path, lookup_needs_distinct, prepare_lookup_value, quote, unquote) +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ImproperlyConfigured, PermissionDenied, SuspiciousOperation +from django.core.paginator import InvalidPage, Paginator +from django.db import models +from django.db.models.constants import LOOKUP_SEP +from django.db.models.fields import FieldDoesNotExist +from django.db.models.fields.related import ForeignObjectRel +from django.db.models.sql.constants import QUERY_TERMS +from django.shortcuts import get_object_or_404, redirect, render +from django.template.defaultfilters import filesizeformat +from django.utils import six +from django.utils.decorators import method_decorator +from django.utils.encoding import force_text +from django.utils.functional import cached_property +from django.utils.http import urlencode +from django.utils.safestring import mark_safe +from django.utils.text import capfirst +from django.utils.translation import ugettext as _ +from django.views.generic import TemplateView +from django.views.generic.edit import FormView + +from wagtail.wagtailadmin import messages +from wagtail.wagtailadmin.edit_handlers import ( + ObjectList, extract_panel_definitions_from_model_class) +from wagtail.wagtaildocs.models import get_document_model +from wagtail.wagtailimages.models import Filter, get_image_model + +from .forms import ParentChooserForm + + +class WMABaseView(TemplateView): + """ + Groups together common functionality for all app views. + """ + model_admin = None + meta_title = '' + page_title = '' + page_subtitle = '' + + def __init__(self, model_admin): + self.model_admin = model_admin + self.model = model_admin.model + self.opts = self.model._meta + self.app_label = force_text(self.opts.app_label) + self.model_name = force_text(self.opts.model_name) + self.verbose_name = force_text(self.opts.verbose_name) + self.verbose_name_plural = force_text(self.opts.verbose_name_plural) + self.pk_attname = self.opts.pk.attname + self.is_pagemodel = model_admin.is_pagemodel + self.permission_helper = model_admin.permission_helper + self.url_helper = model_admin.url_helper + + def check_action_permitted(self, user): + return True + + @method_decorator(login_required) + def dispatch(self, request, *args, **kwargs): + if not self.check_action_permitted(request.user): + raise PermissionDenied + button_helper_class = self.model_admin.get_button_helper_class() + self.button_helper = button_helper_class(self, request) + return super(WMABaseView, self).dispatch(request, *args, **kwargs) + + @cached_property + def menu_icon(self): + return self.model_admin.get_menu_icon() + + @cached_property + def header_icon(self): + return self.menu_icon + + def get_page_title(self): + return self.page_title or capfirst(self.opts.verbose_name_plural) + + def get_meta_title(self): + return self.meta_title or self.get_page_title() + + @cached_property + def index_url(self): + return self.url_helper.index_url + + @cached_property + def create_url(self): + return self.url_helper.create_url + + def get_base_queryset(self, request=None): + return self.model_admin.get_queryset(request or self.request) + + +class ModelFormView(WMABaseView, FormView): + + def get_edit_handler_class(self): + if hasattr(self.model, 'edit_handler'): + edit_handler = self.model.edit_handler + else: + panels = extract_panel_definitions_from_model_class(self.model) + edit_handler = ObjectList(panels) + return edit_handler.bind_to_model(self.model) + + def get_form_class(self): + return self.get_edit_handler_class().get_form_class(self.model) + + def get_success_url(self): + return self.index_url + + def get_instance(self): + return getattr(self, 'instance', None) or self.model() + + def get_form_kwargs(self): + kwargs = FormView.get_form_kwargs(self) + kwargs.update({'instance': self.get_instance()}) + return kwargs + + @property + def media(self): + return forms.Media( + css={'all': self.model_admin.get_form_view_extra_css()}, + js=self.model_admin.get_form_view_extra_js() + ) + + def get_context_data(self, **kwargs): + context = super(ModelFormView, self).get_context_data(**kwargs) + instance = self.get_instance() + edit_handler_class = self.get_edit_handler_class() + form = self.get_form() + context.update({ + 'view': self, + 'model_admin': self.model_admin, + 'is_multipart': form.is_multipart(), + 'edit_handler': edit_handler_class(instance=instance, form=form), + 'form': form, + }) + return context + + def get_success_message(self, instance): + return _("{model_name} '{instance}' created.").format( + model_name=capfirst(self.opts.verbose_name), instance=instance) + + def get_success_message_buttons(self, instance): + button_url = self.url_helper.get_action_url('edit', quote(instance.pk)) + return [ + messages.button(button_url, _('Edit')) + ] + + def get_error_message(self): + model_name = self.verbose_name + return _("The %s could not be created due to errors.") % model_name + + def form_valid(self, form): + instance = form.save() + messages.success( + self.request, self.get_success_message(instance), + buttons=self.get_success_message_buttons(instance) + ) + return redirect(self.get_success_url()) + + def form_invalid(self, form): + messages.error(self.request, self.get_error_message()) + return self.render_to_response(self.get_context_data()) + + +class InstanceSpecificView(WMABaseView): + + instance_pk = None + pk_quoted = None + instance = None + + def __init__(self, model_admin, instance_pk): + super(InstanceSpecificView, self).__init__(model_admin) + self.instance_pk = unquote(instance_pk) + self.pk_quoted = quote(self.instance_pk) + filter_kwargs = {} + filter_kwargs[self.pk_attname] = self.instance_pk + object_qs = model_admin.model._default_manager.get_queryset().filter( + **filter_kwargs) + self.instance = get_object_or_404(object_qs) + + def get_page_subtitle(self): + return self.instance + + @cached_property + def edit_url(self): + return self.url_helper.get_action_url('edit', self.pk_quoted) + + @cached_property + def delete_url(self): + return self.url_helper.get_action_url('delete', self.pk_quoted) + + +class IndexView(WMABaseView): + + # IndexView settings + ORDER_VAR = 'o' + ORDER_TYPE_VAR = 'ot' + PAGE_VAR = 'p' + SEARCH_VAR = 'q' + ERROR_FLAG = 'e' + IGNORED_PARAMS = (ORDER_VAR, ORDER_TYPE_VAR, SEARCH_VAR) + + @method_decorator(login_required) + def dispatch(self, request, *args, **kwargs): + # Only continue if logged in user has list permission + if not self.permission_helper.user_can_list(request.user): + raise PermissionDenied + + self.list_display = self.model_admin.get_list_display(request) + self.list_filter = self.model_admin.get_list_filter(request) + self.search_fields = self.model_admin.get_search_fields(request) + self.items_per_page = self.model_admin.list_per_page + self.select_related = self.model_admin.list_select_related + + # Get search parameters from the query string. + try: + self.page_num = int(request.GET.get(self.PAGE_VAR, 0)) + except ValueError: + self.page_num = 0 + + self.params = dict(request.GET.items()) + if self.PAGE_VAR in self.params: + del self.params[self.PAGE_VAR] + if self.ERROR_FLAG in self.params: + del self.params[self.ERROR_FLAG] + + self.query = request.GET.get(self.SEARCH_VAR, '') + self.queryset = self.get_queryset(request) + + return super(IndexView, self).dispatch(request, *args, **kwargs) + + @property + def media(self): + return forms.Media( + css={'all': self.model_admin.get_index_view_extra_css()}, + js=self.model_admin.get_index_view_extra_js() + ) + + def get_buttons_for_obj(self, obj): + return self.button_helper.get_buttons_for_obj( + obj, classnames_add=['button-small', 'button-secondary']) + + def get_search_results(self, request, queryset, search_term): + """ + Returns a tuple containing a queryset to implement the search, + and a boolean indicating if the results may contain duplicates. + """ + use_distinct = False + if self.search_fields and search_term: + orm_lookups = ['%s__icontains' % str(search_field) + for search_field in self.search_fields] + for bit in search_term.split(): + or_queries = [models.Q(**{orm_lookup: bit}) + for orm_lookup in orm_lookups] + queryset = queryset.filter(reduce(operator.or_, or_queries)) + if not use_distinct: + for search_spec in orm_lookups: + if lookup_needs_distinct(self.opts, search_spec): + use_distinct = True + break + + return queryset, use_distinct + + def lookup_allowed(self, lookup, value): + # Check FKey lookups that are allowed, so that popups produced by + # ForeignKeyRawIdWidget, on the basis of ForeignKey.limit_choices_to, + # are allowed to work. + for l in self.model._meta.related_fkey_lookups: + for k, v in widgets.url_params_from_lookup_dict(l).items(): + if k == lookup and v == value: + return True + + parts = lookup.split(LOOKUP_SEP) + + # Last term in lookup is a query term (__exact, __startswith etc) + # This term can be ignored. + if len(parts) > 1 and parts[-1] in QUERY_TERMS: + parts.pop() + + # Special case -- foo__id__exact and foo__id queries are implied + # if foo has been specifically included in the lookup list; so + # drop __id if it is the last part. However, first we need to find + # the pk attribute name. + rel_name = None + for part in parts[:-1]: + try: + field = self.model._meta.get_field(part) + except FieldDoesNotExist: + # Lookups on non-existent fields are ok, since they're ignored + # later. + return True + if hasattr(field, 'rel'): + if field.rel is None: + # This property or relation doesn't exist, but it's allowed + # since it's ignored in ChangeList.get_filters(). + return True + model = field.rel.to + rel_name = field.rel.get_related_field().name + elif isinstance(field, ForeignObjectRel): + model = field.model + rel_name = model._meta.pk.name + else: + rel_name = None + if rel_name and len(parts) > 1 and parts[-1] == rel_name: + parts.pop() + + if len(parts) == 1: + return True + clean_lookup = LOOKUP_SEP.join(parts) + return clean_lookup in self.list_filter + + def get_filters_params(self, params=None): + """ + Returns all params except IGNORED_PARAMS + """ + if not params: + params = self.params + lookup_params = params.copy() # a dictionary of the query string + # Remove all the parameters that are globally and systematically + # ignored. + for ignored in self.IGNORED_PARAMS: + if ignored in lookup_params: + del lookup_params[ignored] + return lookup_params + + def get_filters(self, request): + lookup_params = self.get_filters_params() + use_distinct = False + + for key, value in lookup_params.items(): + if not self.lookup_allowed(key, value): + raise DisallowedModelAdminLookup( + "Filtering by %s not allowed" % key) + + filter_specs = [] + if self.list_filter: + for list_filter in self.list_filter: + if callable(list_filter): + # This is simply a custom list filter class. + spec = list_filter( + request, + lookup_params, + self.model, + self.model_admin) + else: + field_path = None + if isinstance(list_filter, (tuple, list)): + # This is a custom FieldListFilter class for a given + # field. + field, field_list_filter_class = list_filter + else: + # This is simply a field name, so use the default + # FieldListFilter class that has been registered for + # the type of the given field. + field = list_filter + field_list_filter_class = FieldListFilter.create + if not isinstance(field, models.Field): + field_path = field + field = get_fields_from_path(self.model, + field_path)[-1] + spec = field_list_filter_class( + field, + request, + lookup_params, + self.model, + self.model_admin, + field_path=field_path) + + # Check if we need to use distinct() + use_distinct = ( + use_distinct or lookup_needs_distinct(self.opts, + field_path)) + if spec and spec.has_output(): + filter_specs.append(spec) + + # At this point, all the parameters used by the various ListFilters + # have been removed from lookup_params, which now only contains other + # parameters passed via the query string. We now loop through the + # remaining parameters both to ensure that all the parameters are valid + # fields and to determine if at least one of them needs distinct(). If + # the lookup parameters aren't real fields, then bail out. + try: + for key, value in lookup_params.items(): + lookup_params[key] = prepare_lookup_value(key, value) + use_distinct = ( + use_distinct or lookup_needs_distinct(self.opts, key)) + return ( + filter_specs, bool(filter_specs), lookup_params, use_distinct + ) + except FieldDoesNotExist as e: + six.reraise( + IncorrectLookupParameters, + IncorrectLookupParameters(e), + sys.exc_info()[2]) + + def get_query_string(self, new_params=None, remove=None): + if new_params is None: + new_params = {} + if remove is None: + remove = [] + p = self.params.copy() + for r in remove: + for k in list(p): + if k.startswith(r): + del p[k] + for k, v in new_params.items(): + if v is None: + if k in p: + del p[k] + else: + p[k] = v + return '?%s' % urlencode(sorted(p.items())) + + def _get_default_ordering(self): + ordering = [] + if self.model_admin.ordering: + ordering = self.model_admin.ordering + elif self.opts.ordering: + ordering = self.opts.ordering + return ordering + + def get_default_ordering(self, request): + if self.model_admin.get_ordering(request): + return self.model_admin.get_ordering(request) + if self.opts.ordering: + return self.opts.ordering + return () + + def get_ordering_field(self, field_name): + """ + Returns the proper model field name corresponding to the given + field_name to use for ordering. field_name may either be the name of a + proper model field or the name of a method (on the admin or model) or a + callable with the 'admin_order_field' attribute. Returns None if no + proper model field name can be matched. + """ + try: + field = self.opts.get_field(field_name) + return field.name + except FieldDoesNotExist: + # See whether field_name is a name of a non-field + # that allows sorting. + if callable(field_name): + attr = field_name + elif hasattr(self.model_admin, field_name): + attr = getattr(self.model_admin, field_name) + else: + attr = getattr(self.model, field_name) + return getattr(attr, 'admin_order_field', None) + + def get_ordering(self, request, queryset): + """ + Returns the list of ordering fields for the change list. + First we check the get_ordering() method in model admin, then we check + the object's default ordering. Then, any manually-specified ordering + from the query string overrides anything. Finally, a deterministic + order is guaranteed by ensuring the primary key is used as the last + ordering field. + """ + params = self.params + ordering = list(self.get_default_ordering(request)) + if self.ORDER_VAR in params: + # Clear ordering and used params + ordering = [] + order_params = params[self.ORDER_VAR].split('.') + for p in order_params: + try: + none, pfx, idx = p.rpartition('-') + field_name = self.list_display[int(idx)] + order_field = self.get_ordering_field(field_name) + if not order_field: + continue # No 'admin_order_field', skip it + # reverse order if order_field has already "-" as prefix + if order_field.startswith('-') and pfx == "-": + ordering.append(order_field[1:]) + else: + ordering.append(pfx + order_field) + except (IndexError, ValueError): + continue # Invalid ordering specified, skip it. + + # Add the given query's ordering fields, if any. + ordering.extend(queryset.query.order_by) + + # Ensure that the primary key is systematically present in the list of + # ordering fields so we can guarantee a deterministic order across all + # database backends. + pk_name = self.opts.pk.name + if not (set(ordering) & {'pk', '-pk', pk_name, '-' + pk_name}): + # The two sets do not intersect, meaning the pk isn't present. So + # we add it. + ordering.append('-pk') + + return ordering + + def get_ordering_field_columns(self): + """ + Returns an OrderedDict of ordering field column numbers and asc/desc + """ + + # We must cope with more than one column having the same underlying + # sort field, so we base things on column numbers. + ordering = self._get_default_ordering() + ordering_fields = OrderedDict() + if self.ORDER_VAR not in self.params: + # for ordering specified on model_admin or model Meta, we don't + # know the right column numbers absolutely, because there might be + # morr than one column associated with that ordering, so we guess. + for field in ordering: + if field.startswith('-'): + field = field[1:] + order_type = 'desc' + else: + order_type = 'asc' + for index, attr in enumerate(self.list_display): + if self.get_ordering_field(attr) == field: + ordering_fields[index] = order_type + break + else: + for p in self.params[self.ORDER_VAR].split('.'): + none, pfx, idx = p.rpartition('-') + try: + idx = int(idx) + except ValueError: + continue # skip it + ordering_fields[idx] = 'desc' if pfx == '-' else 'asc' + return ordering_fields + + def get_queryset(self, request=None): + request = request or self.request + + # First, we collect all the declared list filters. + (self.filter_specs, self.has_filters, remaining_lookup_params, + filters_use_distinct) = self.get_filters(request) + + # Then, we let every list filter modify the queryset to its liking. + qs = self.get_base_queryset(request) + for filter_spec in self.filter_specs: + new_qs = filter_spec.queryset(request, qs) + if new_qs is not None: + qs = new_qs + + try: + # Finally, we apply the remaining lookup parameters from the query + # string (i.e. those that haven't already been processed by the + # filters). + qs = qs.filter(**remaining_lookup_params) + except (SuspiciousOperation, ImproperlyConfigured): + # Allow certain types of errors to be re-raised as-is so that the + # caller can treat them in a special way. + raise + except Exception as e: + # Every other error is caught with a naked except, because we don't + # have any other way of validating lookup parameters. They might be + # invalid if the keyword arguments are incorrect, or if the values + # are not in the correct type, so we might get FieldError, + # ValueError, ValidationError, or ?. + raise IncorrectLookupParameters(e) + + if not qs.query.select_related: + qs = self.apply_select_related(qs) + + # Set ordering. + ordering = self.get_ordering(request, qs) + qs = qs.order_by(*ordering) + + # Apply search results + qs, search_use_distinct = self.get_search_results( + request, qs, self.query) + + # Remove duplicates from results, if necessary + if filters_use_distinct | search_use_distinct: + return qs.distinct() + else: + return qs + + def apply_select_related(self, qs): + if self.select_related is True: + return qs.select_related() + + if self.select_related is False: + if self.has_related_field_in_list_display(): + return qs.select_related() + + if self.select_related: + return qs.select_related(*self.select_related) + return qs + + def has_related_field_in_list_display(self): + for field_name in self.list_display: + try: + field = self.opts.get_field(field_name) + except FieldDoesNotExist: + pass + else: + if isinstance(field, models.ManyToOneRel): + return True + return False + + def get_context_data(self, *args, **kwargs): + user = self.request.user + all_count = self.get_base_queryset().count() + queryset = self.get_queryset() + result_count = queryset.count() + paginator = Paginator(queryset, self.items_per_page) + + try: + page_obj = paginator.page(self.page_num + 1) + except InvalidPage: + page_obj = paginator.page(1) + + context = { + 'view': self, + 'all_count': all_count, + 'result_count': result_count, + 'paginator': paginator, + 'page_obj': page_obj, + 'object_list': page_obj.object_list, + 'user_can_create': self.permission_helper.user_can_create(user) + } + + if self.is_pagemodel: + models = self.model.allowed_parent_page_models() + allowed_parent_types = [m._meta.verbose_name for m in models] + valid_parents = self.permission_helper.get_valid_parent_pages(user) + valid_parent_count = valid_parents.count() + context.update({ + 'no_valid_parents': not valid_parent_count, + 'required_parent_types': allowed_parent_types, + }) + return context + + def get_template_names(self): + return self.model_admin.get_index_template() + + +class CreateView(ModelFormView): + page_title = _('New') + + def check_action_permitted(self, user): + return self.permission_helper.user_can_create(user) + + def dispatch(self, request, *args, **kwargs): + if self.is_pagemodel: + user = request.user + parents = self.permission_helper.get_valid_parent_pages(user) + parent_count = parents.count() + + # There's only one available parent for this page type for this + # user, so we send them along with that as the chosen parent page + if parent_count == 1: + parent = parents.get() + parent_pk = quote(parent.pk) + return redirect(self.url_helper.get_action_url( + 'add', self.app_label, self.model_name, parent_pk)) + + # The page can be added in multiple places, so redirect to the + # choose_parent view so that the parent can be specified + return redirect(self.url_helper.get_action_url('choose_parent')) + return super(CreateView, self).dispatch(request, *args, **kwargs) + + def get_meta_title(self): + return _('Create new %s') % self.verbose_name + + def get_page_subtitle(self): + return capfirst(self.verbose_name) + + def get_template_names(self): + return self.model_admin.get_create_template() + + +class EditView(ModelFormView, InstanceSpecificView): + page_title = _('Editing') + + def check_action_permitted(self, user): + return self.permission_helper.user_can_edit_obj(user, self.instance) + + @method_decorator(login_required) + def dispatch(self, request, *args, **kwargs): + if self.is_pagemodel: + return redirect( + self.url_helper.get_action_url('edit', self.pk_quoted) + ) + return super(EditView, self).dispatch(request, *args, **kwargs) + + def get_meta_title(self): + return _('Editing %s') % self.verbose_name + + def get_success_message(self, instance): + return _("{model_name} '{instance}' updated.").format( + model_name=capfirst(self.verbose_name), instance=instance) + + def get_context_data(self, **kwargs): + kwargs['user_can_delete'] = self.permission_helper.user_can_delete_obj( + self.request.user, self.instance) + return super(EditView, self).get_context_data(**kwargs) + + def get_error_message(self): + name = self.verbose_name + return _("The %s could not be saved due to errors.") % name + + def get_template_names(self): + return self.model_admin.get_edit_template() + + +class ChooseParentView(WMABaseView): + def dispatch(self, request, *args, **kwargs): + if not self.permission_helper.user_can_create(request.user): + raise PermissionDenied + return super(ChooseParentView, self).dispatch(request, *args, **kwargs) + + def get_page_title(self): + return _('Add %s') % self.verbose_name + + def get_form(self, request): + parents = self.permission_helper.get_valid_parent_pages(request.user) + return ParentChooserForm(parents, request.POST or None) + + def get(self, request, *args, **kwargs): + form = self.get_form(request) + context = {'view': self, 'form': form} + return render(request, self.get_template(), context) + + def post(self, request, *args, **kargs): + form = self.get_form(request) + if form.is_valid(): + parent_pk = quote(form.cleaned_data['parent_page'].pk) + return redirect(self.url_helper.get_action_url( + 'add', self.app_label, self.model_name, parent_pk)) + + context = {'view': self, 'form': form} + return render(request, self.get_template(), context) + + def get_template(self): + return self.model_admin.get_choose_parent_template() + + +class DeleteView(InstanceSpecificView): + page_title = _('Delete') + + def check_action_permitted(self, user): + return self.permission_helper.user_can_delete_obj(user, self.instance) + + @method_decorator(login_required) + def dispatch(self, request, *args, **kwargs): + if not self.check_action_permitted(request.user): + raise PermissionDenied + if self.is_pagemodel: + return redirect( + self.url_helper.get_action_url('delete', self.pk_quoted) + ) + return super(DeleteView, self).dispatch(request, *args, **kwargs) + + def get_meta_title(self): + return _('Confirm deletion of %s') % self.verbose_name + + def confirmation_message(self): + return _( + "Are you sure you want to delete this %s? If other things in your " + "site are related to it, they may also be affected." + ) % self.verbose_name + + def get(self, request, *args, **kwargs): + context = {'view': self, 'instance': self.instance} + return self.render_to_response(context) + + def delete_instance(self): + self.instance.delete() + + def post(self, request, *args, **kwargs): + try: + self.delete_instance() + messages.success( + request, + _("{model} '{instance}' deleted.").format( + model=self.verbose_name, instance=self.instance)) + return redirect(self.index_url) + except models.ProtectedError: + linked_objects = [] + for rel in self.model._meta.get_all_related_objects(): + if rel.on_delete == models.PROTECT: + qs = getattr(self.instance, rel.get_accessor_name()) + for obj in qs.all(): + linked_objects.append(obj) + + context = { + 'view': self, + 'instance': self.instance, + 'protected_error': True, + 'linked_objects': linked_objects, + } + return self.render_to_response(context) + + def get_template_names(self): + return self.model_admin.get_delete_template() + + +class InspectView(InstanceSpecificView): + + page_title = _('Inspecting') + + def check_action_permitted(self, user): + return self.permission_helper.user_can_inspect_obj(user, self.instance) + + @property + def media(self): + return forms.Media( + css={'all': self.model_admin.get_inspect_view_extra_css()}, + js=self.model_admin.get_inspect_view_extra_js() + ) + + def get_meta_title(self): + return _('Inspecting %s') % self.verbose_name + + def get_field_label(self, field_name, field=None): + """ Return a label to display for a field """ + label = None + if field is not None: + label = getattr(field, 'verbose_name', None) + if label is None: + label = getattr(field, 'name', None) + if label is None: + label = field_name + return label + + def get_field_display_value(self, field_name, field=None): + """ Return a display value for a field """ + + # First we check for a 'get_fieldname_display' property/method on + # the model, and return the value of that, if present. + val_funct = getattr(self.instance, 'get_%s_display' % field_name, None) + if val_funct is not None: + if callable(val_funct): + return val_funct() + return val_funct + + # If we have a real field, we can utilise that to try to display + # something more useful + if field is not None: + try: + field_type = field.get_internal_type() + if ( + field_type == 'ForeignKey' and + field.related_model == get_image_model() + ): + # The field is an image + return self.get_image_field_display(field_name, field) + + if ( + field_type == 'ForeignKey' and + field.related_model == get_document_model() + ): + # The field is a document + return self.get_document_field_display(field_name, field) + + except AttributeError: + pass + + # Resort to getting the value of 'field_name' from the instance + return getattr(self.instance, field_name, + self.model_admin.get_empty_value_display(field_name)) + + def get_image_field_display(self, field_name, field): + """ Render an image """ + image = getattr(self.instance, field_name) + if image: + fltr, _ = Filter.objects.get_or_create(spec='max-400x400') + rendition = image.get_rendition(fltr) + return rendition.img_tag + return self.model_admin.get_empty_value_display(field_name) + + def get_document_field_display(self, field_name, field): + """ Render a link to a document """ + document = getattr(self.instance, field_name) + if document: + return mark_safe( + '%s (%s, %s)' % ( + document.url, + document.title, + document.file_extension.upper(), + filesizeformat(document.file.size), + ) + ) + return self.model_admin.get_empty_value_display(field_name) + + def get_dict_for_field(self, field_name): + """ + Return a dictionary containing `label` and `value` values to display + for a field. + """ + try: + field = self.model._meta.get_field(field_name) + except FieldDoesNotExist: + field = None + return { + 'label': self.get_field_label(field_name, field), + 'value': self.get_field_display_value(field_name, field), + } + + def get_fields_dict(self): + """ + Return a list of `label`/`value` dictionaries to represent the + fiels named by the model_admin class's `get_inspect_view_fields` method + """ + fields = [] + for field_name in self.model_admin.get_inspect_view_fields(): + fields.append(self.get_dict_for_field(field_name)) + return fields + + def get_context_data(self, **kwargs): + context = super(InspectView, self).get_context_data(**kwargs) + buttons = self.button_helper.get_buttons_for_obj( + self.instance, exclude=['inspect']) + context.update({ + 'view': self, + 'fields': self.get_fields_dict(), + 'buttons': buttons, + 'instance': self.instance, + }) + return context + + def get_template_names(self): + return self.model_admin.get_inspect_template() diff --git a/wagtail/tests/modeladmintest/__init__.py b/wagtail/tests/modeladmintest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/tests/modeladmintest/apps.py b/wagtail/tests/modeladmintest/apps.py new file mode 100644 index 000000000..7267f79cc --- /dev/null +++ b/wagtail/tests/modeladmintest/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WagtailTestsAppConfig(AppConfig): + name = 'wagtail.tests.modeladmintest' + label = 'test_modeladmintest' + verbose_name = "Test Wagtail Model Admin" diff --git a/wagtail/tests/modeladmintest/fixtures/modeladmintest_test.json b/wagtail/tests/modeladmintest/fixtures/modeladmintest_test.json new file mode 100644 index 000000000..4a240f758 --- /dev/null +++ b/wagtail/tests/modeladmintest/fixtures/modeladmintest_test.json @@ -0,0 +1,66 @@ +[ +{ + "pk": 1, + "model": "modeladmintest.author", + "fields": { + "name": "J. R. R. Tolkien", + "date_of_birth": "1892-01-03" + } +}, +{ + "pk": 2, + "model": "modeladmintest.author", + "fields": { + "name": "Roald Dahl", + "date_of_birth": "1916-09-13" + } +}, +{ + "pk": 3, + "model": "modeladmintest.author", + "fields": { + "name": "Roald Dahl", + "date_of_birth": "1898-11-29" + } +}, +{ + "pk": 4, + "model": "modeladmintest.author", + "fields": { + "name": "J. R. Hartley", + "date_of_birth": "1898-11-29" + } +}, +{ + "pk": 1, + "model": "modeladmintest.book", + "fields": { + "title": "The Lord of the Rings", + "author_id": 1 + } +}, +{ + "pk": 2, + "model": "modeladmintest.book", + "fields": { + "title": "The Hobbit", + "author_id": 1 + } +}, +{ + "pk": 3, + "model": "modeladmintest.book", + "fields": { + "title": "Charlie and the Chocolate Factory", + "author_id": 2 + } +}, +{ + "pk": 4, + "model": "modeladmintest.book", + "fields": { + "title": "The Chronicles of Narnia", + "author_id": 3 + } +} +] diff --git a/wagtail/tests/modeladmintest/migrations/0001_initial.py b/wagtail/tests/modeladmintest/migrations/0001_initial.py new file mode 100644 index 000000000..0e4b028cb --- /dev/null +++ b/wagtail/tests/modeladmintest/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-22 11:40 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import wagtail.wagtailsearch.index + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('date_of_birth', models.DateField()), + ], + ), + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='modeladmintest.Author')), + ], + bases=(models.Model, wagtail.wagtailsearch.index.Indexed), + ), + ] diff --git a/wagtail/tests/modeladmintest/migrations/__init__.py b/wagtail/tests/modeladmintest/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/wagtail/tests/modeladmintest/models.py b/wagtail/tests/modeladmintest/models.py new file mode 100644 index 000000000..a70eada64 --- /dev/null +++ b/wagtail/tests/modeladmintest/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.utils.encoding import python_2_unicode_compatible + +from wagtail.wagtailsearch import index + + +@python_2_unicode_compatible +class Author(models.Model): + name = models.CharField(max_length=255) + date_of_birth = models.DateField() + + def __str__(self): + return self.name + + +@python_2_unicode_compatible +class Book(models.Model, index.Indexed): + author = models.ForeignKey(Author, on_delete=models.PROTECT) + title = models.CharField(max_length=255) + + def __str__(self): + return self.title diff --git a/wagtail/tests/modeladmintest/wagtail_hooks.py b/wagtail/tests/modeladmintest/wagtail_hooks.py new file mode 100644 index 000000000..4b60774ca --- /dev/null +++ b/wagtail/tests/modeladmintest/wagtail_hooks.py @@ -0,0 +1,57 @@ +from wagtail.contrib.modeladmin.options import ( + ModelAdmin, ModelAdminGroup, modeladmin_register) +from .models import Author, Book +from wagtail.tests.testapp.models import ( + EventPage, SingleEventPage, BusinessChild +) + + +class AuthorModelAdmin(ModelAdmin): + model = Author + menu_order = 200 + list_display = ('name', 'date_of_birth') + list_filter = ('date_of_birth', ) + search_fields = ('name', ) + inspect_view_enabled = True + inspect_view_fields = ('name', ) + + +class BookModelAdmin(ModelAdmin): + model = Book + menu_order = 300 + list_display = ('title', 'author') + list_filter = ('author', ) + search_fields = ('title', ) + inspect_view_enabled = True + inspect_view_fields_exclude = ('title', ) + + +class EventPageAdmin(ModelAdmin): + model = EventPage + list_display = ('title', 'date_from', 'audience') + list_filter = ('audience', ) + search_fields = ('title', ) + inspect_view_enabled = True + inspect_view_fields_exclude = ('feed_image', ) + + +class SingleEventPageAdmin(EventPageAdmin): + model = SingleEventPage + + +class EventsAdminGroup(ModelAdminGroup): + menu_label = "Events" + items = (EventPageAdmin, SingleEventPageAdmin) + menu_order = 500 + + +class BusinessChildAdmin(ModelAdmin): + model = BusinessChild + # having "Business Child" permanently in the menu confuses tests for the 'add page' view + menu_label = "BusinessSprog" + + +modeladmin_register(AuthorModelAdmin) +modeladmin_register(BookModelAdmin) +modeladmin_register(EventsAdminGroup) +modeladmin_register(BusinessChildAdmin) diff --git a/wagtail/tests/settings.py b/wagtail/tests/settings.py index 53e695245..6250ed109 100644 --- a/wagtail/tests/settings.py +++ b/wagtail/tests/settings.py @@ -77,7 +77,6 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'wagtail.wagtailcore.middleware.SiteMiddleware', - 'wagtail.wagtailredirects.middleware.RedirectMiddleware', ) @@ -93,6 +92,7 @@ INSTALLED_APPS = ( 'wagtail.tests.snippets', 'wagtail.tests.routablepage', 'wagtail.tests.search', + 'wagtail.tests.modeladmintest', 'wagtail.contrib.wagtailstyleguide', 'wagtail.contrib.wagtailsitemaps', 'wagtail.contrib.wagtailroutablepage', @@ -100,6 +100,7 @@ INSTALLED_APPS = ( 'wagtail.contrib.wagtailapi', 'wagtail.contrib.wagtailsearchpromotions', 'wagtail.contrib.settings', + 'wagtail.contrib.modeladmin', 'wagtail.wagtailforms', 'wagtail.wagtailsearch', 'wagtail.wagtailembeds', diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_delete.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_delete.html index 5c2a3c404..db3a3b8f3 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_delete.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_delete.html @@ -23,6 +23,7 @@ {% endif %}
{% csrf_token %} + {% if page_perms.can_unpublish %}{% trans 'Unpublish it' %}{% endif %}
diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_unpublish.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_unpublish.html index 969a7cbe2..078ed1e64 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_unpublish.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/confirm_unpublish.html @@ -9,6 +9,7 @@

{% trans "Are you sure you want to unpublish this page?" %}

{% csrf_token %} +
diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/copy.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/copy.html index 6c9ccaefe..2b3b570ef 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/copy.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/copy.html @@ -8,6 +8,7 @@
{% csrf_token %} +
    {% include "wagtailadmin/shared/field_as_li.html" with field=form.new_title %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html index 13e8916dc..12df1880d 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/create.html @@ -19,6 +19,7 @@ {% csrf_token %} + {{ edit_handler.render_form_content }} {% page_permissions parent_page as parent_page_perms %} diff --git a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html index ed8ee0a19..d60f63cc5 100644 --- a/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html +++ b/wagtail/wagtailadmin/templates/wagtailadmin/pages/edit.html @@ -26,6 +26,7 @@ {% csrf_token %} + {{ edit_handler.render_form_content }} {% if is_revision %} diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py index df94e77bf..af4cad01c 100644 --- a/wagtail/wagtailadmin/views/pages.py +++ b/wagtail/wagtailadmin/views/pages.py @@ -9,6 +9,8 @@ from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string from django.utils import timezone from django.utils.http import is_safe_url +from django.utils.translation import ugettext as _ +from django.utils.http import is_safe_url, urlquote from django.utils.safestring import mark_safe from django.utils.translation import ugettext as _ from django.views.decorators.http import require_GET, require_POST @@ -22,6 +24,13 @@ from wagtail.wagtailcore import hooks from wagtail.wagtailcore.models import Page, PageRevision, get_navigation_menu_items +def get_valid_next_url_from_request(request): + next_url = request.POST.get('next') or request.GET.get('next') + if not next_url or not is_safe_url(url=next_url, host=request.get_host()): + return '' + return next_url + + def explorer_nav(request): return render(request, 'wagtailadmin/shared/explorer_nav.html', { 'nodes': get_navigation_menu_items(), @@ -172,6 +181,8 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ edit_handler_class = page_class.get_edit_handler() form_class = edit_handler_class.get_form_class(page_class) + next_url = get_valid_next_url_from_request(request) + if request.method == 'POST': form = form_class(request.POST, request.FILES, instance=page, parent_page=parent_page) @@ -229,11 +240,19 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ return result if is_publishing or is_submitting: - # we're done here - redirect back to the explorer + # we're done here + if next_url: + # redirect back to 'next' url if present + return redirect(next_url) + # redirect back to the explorer 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) + target_url = reverse('wagtailadmin_pages:edit', args=[page.id]) + if next_url: + # Ensure the 'next' url is passed through again if present + target_url += '?next=%s' % urlquote(next_url) + return redirect(target_url) else: messages.error(request, _("The page could not be created due to validation errors")) edit_handler = edit_handler_class(instance=page, form=form) @@ -249,6 +268,7 @@ def create(request, content_type_app_name, content_type_model_name, parent_page_ 'edit_handler': edit_handler, 'preview_modes': page.preview_modes, 'form': form, + 'next': next_url, }) @@ -267,6 +287,8 @@ def edit(request, page_id): edit_handler_class = page_class.get_edit_handler() form_class = edit_handler_class.get_form_class(page_class) + next_url = get_valid_next_url_from_request(request) + errors_debug = None if request.method == 'POST': @@ -399,10 +421,18 @@ def edit(request, page_id): if is_publishing or is_submitting: # we're done here - redirect back to the explorer + if next_url: + # redirect back to 'next' url if present + return redirect(next_url) + # redirect back to the explorer 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) + target_url = reverse('wagtailadmin_pages:edit', args=[page.id]) + if next_url: + # Ensure the 'next' url is passed through again if present + target_url += '?next=%s' % urlquote(next_url) + return redirect(target_url) else: if page.locked: messages.error(request, _("The page could not be saved as it is locked")) @@ -433,6 +463,7 @@ def edit(request, page_id): 'errors_debug': errors_debug, 'preview_modes': page.preview_modes, 'form': form, + 'next': next_url, }) @@ -441,6 +472,8 @@ def delete(request, page_id): if not page.permissions_for_user(request.user).can_delete(): raise PermissionDenied + next_url = get_valid_next_url_from_request(request) + if request.method == 'POST': parent_id = page.get_parent().id page.delete() @@ -452,11 +485,14 @@ def delete(request, page_id): if hasattr(result, 'status_code'): return result + if next_url: + return redirect(next_url) return redirect('wagtailadmin_explore', parent_id) return render(request, 'wagtailadmin/pages/confirm_delete.html', { 'page': page, - 'descendant_count': page.get_descendant_count() + 'descendant_count': page.get_descendant_count(), + 'next': next_url, }) @@ -588,9 +624,12 @@ def preview_loading(request): def unpublish(request, page_id): page = get_object_or_404(Page, id=page_id).specific + if not page.permissions_for_user(request.user).can_unpublish(): raise PermissionDenied + next_url = get_valid_next_url_from_request(request) + if request.method == 'POST': page.unpublish() @@ -598,10 +637,13 @@ def unpublish(request, page_id): messages.button(reverse('wagtailadmin_pages:edit', args=(page.id,)), _('Edit')) ]) + if next_url: + return redirect(next_url) return redirect('wagtailadmin_explore', page.get_parent().id) return render(request, 'wagtailadmin/pages/confirm_unpublish.html', { 'page': page, + 'next': next_url, }) @@ -647,7 +689,6 @@ def move_confirm(request, page_to_move_id, destination_id): if request.method == 'POST': # any invalid moves *should* be caught by the permission check above, # so don't bother to catch InvalidMoveToDescendant - page_to_move.move(destination, pos='last-child') messages.success(request, _("Page '{0}' moved.").format(page_to_move.title), buttons=[ @@ -713,6 +754,8 @@ def copy(request, page_id): # Create the form form = CopyForm(request.POST or None, page=page, can_publish=can_publish) + next_url = get_valid_next_url_from_request(request) + # Check if user is submitting if request.method == 'POST': # Prefill parent_page in case the form is invalid (as prepopulated value for the form field, @@ -753,11 +796,14 @@ def copy(request, page_id): messages.success(request, _("Page '{0}' copied.").format(page.title)) # Redirect to explore of parent page + if next_url: + return redirect(next_url) return redirect('wagtailadmin_explore', parent_page.id) return render(request, 'wagtailadmin/pages/copy.html', { 'page': page, 'form': form, + 'next': next_url, })