Limit explorer menu nav to the subtree the user has permission over

Partially addresses #2401; adapted from #2463.

Updates the explorer-nav logic to take the user's permissions into account.
The menu now begins at the closest common ancestor node of all pages they
have add/edit/publish/lock permission for - as a result, users with
permission over a specific deep section of the tree don't have to redundantly
drill down to it, and we're a step closer to true 'multi-homed' installations
where the user is not made aware of tree structure that exists outside of
their own remit.
pull/2869/merge
Matt Westcott 2016-07-26 14:00:17 +01:00
rodzic 043db8549d
commit 39319f2191
6 zmienionych plików z 741 dodań i 31 usunięć

Wyświetl plik

@ -0,0 +1,512 @@
[
{
"pk": 1,
"model": "wagtailcore.page",
"fields": {
"title": "Root",
"numchild": 1,
"show_in_menus": false,
"live": true,
"depth": 1,
"content_type": ["wagtailcore", "page"],
"path": "0001",
"url_path": "/",
"slug": "root"
}
},
{
"pk": 2,
"model": "wagtailcore.page",
"fields": {
"title": "Welcome to testserver!",
"numchild": 1,
"show_in_menus": false,
"live": true,
"depth": 2,
"content_type": ["tests", "eventpage"],
"path": "00010001",
"url_path": "/home/",
"slug": "home"
}
},
{
"pk": 2,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 3,
"model": "wagtailcore.page",
"fields": {
"title": "About us",
"numchild": 0,
"show_in_menus": true,
"live": true,
"depth": 3,
"content_type": ["tests", "eventpage"],
"path": "000100010001",
"url_path": "/home/about-us/",
"slug": "about-us"
}
},
{
"pk": 3,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 4,
"model": "wagtailcore.page",
"fields": {
"title": "Welcome to example.com!",
"numchild": 1,
"show_in_menus": false,
"live": true,
"depth": 2,
"content_type": ["tests", "eventpage"],
"path": "00010002",
"url_path": "/example-home/",
"slug": "example-home"
}
},
{
"pk": 4,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 5,
"model": "wagtailcore.page",
"fields": {
"title": "Content",
"numchild": 2,
"show_in_menus": true,
"live": true,
"depth": 3,
"content_type": ["tests", "eventpage"],
"path": "000100020001",
"url_path": "/example-home/content/",
"slug": "content",
"owner": 1
}
},
{
"pk": 5,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 6,
"model": "wagtailcore.page",
"fields": {
"title": "Page 1",
"numchild": 0,
"show_in_menus": true,
"live": true,
"depth": 4,
"content_type": ["tests", "eventpage"],
"path": "0001000200010001",
"url_path": "/example-home/content/page-1/",
"slug": "page-1",
"owner": 1
}
},
{
"pk": 6,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 7,
"model": "wagtailcore.page",
"fields": {
"title": "Page 2",
"numchild": 1,
"show_in_menus": true,
"live": true,
"depth": 4,
"content_type": ["tests", "eventpage"],
"path": "0001000200010002",
"url_path": "/example-home/content/page-2/",
"slug": "page-2",
"owner": 1
}
},
{
"pk": 7,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 8,
"model": "wagtailcore.page",
"fields": {
"title": "Other Content",
"numchild": 0,
"show_in_menus": true,
"live": true,
"depth": 3,
"content_type": ["tests", "eventpage"],
"path": "000100020002",
"url_path": "/example-home/other-content/",
"slug": "other-content",
"owner": 1
}
},
{
"pk": 8,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 9,
"model": "wagtailcore.page",
"fields": {
"title": "Child 1 of Page 2",
"numchild": 0,
"show_in_menus": true,
"live": true,
"depth": 5,
"content_type": ["tests", "eventpage"],
"path": "00010002000100020001",
"url_path": "/example-home/content/page-2/child-1/",
"slug": "child-1",
"owner": 1
}
},
{
"pk": 9,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "public",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 10,
"model": "wagtailcore.page",
"fields": {
"title": "Welcome to example2.com!",
"numchild": 0,
"show_in_menus": false,
"live": true,
"depth": 2,
"content_type": ["tests", "eventpage"],
"path": "00010003",
"url_path": "/home-2/",
"slug": "home-2"
}
},
{
"pk": 10,
"model": "tests.eventpage",
"fields": {
"date_from": "2014-12-25",
"audience": "private",
"location": "The North Pole",
"body": "<p>Welcome!</p>",
"cost": "Free"
}
},
{
"pk": 1,
"model": "wagtailcore.site",
"fields": {
"root_page": 2,
"hostname": "testserver",
"port": 80,
"is_default_site": true
}
},
{
"pk": 2,
"model": "wagtailcore.site",
"fields": {
"root_page": 4,
"hostname": "example.com",
"port": 80,
"is_default_site": false
}
},
{
"pk": 3,
"model": "wagtailcore.site",
"fields": {
"root_page": 10,
"hostname": "example2.com",
"port": 80,
"is_default_site": false
}
},
{
"pk": 3,
"model": "auth.group",
"fields": {
"name": "Group 1",
"permissions": [
["access_admin", "wagtailadmin", "admin"]
]
}
},
{
"pk": 4,
"model": "auth.group",
"fields": {
"name": "Group 2",
"permissions": [
["access_admin", "wagtailadmin", "admin"]
]
}
},
{
"pk": 5,
"model": "auth.group",
"fields": {
"name": "Group 3",
"permissions": [
["access_admin", "wagtailadmin", "admin"]
]
}
},
{
"pk": 1,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 1"],
"page": 2,
"permission_type": "add"
}
},
{
"pk": 2,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 1"],
"page": 2,
"permission_type": "edit"
}
},
{
"pk": 3,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 1"],
"page": 2,
"permission_type": "publish"
}
},
{
"pk": 3,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 1"],
"page": 2,
"permission_type": "choose"
}
},
{
"pk": 5,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 2"],
"page": 6,
"permission_type": "edit"
}
},
{
"pk": 6,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 2"],
"page": 6,
"permission_type": "choose"
}
},
{
"pk": 7,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 3"],
"page": 8,
"permission_type": "edit"
}
},
{
"pk": 8,
"model": "wagtailcore.grouppagepermission",
"fields": {
"group": ["Group 3"],
"page": 8,
"permission_type": "choose"
}
},
{
"pk": 1,
"model": "customuser.customuser",
"fields": {
"username": "superman",
"first_name": "Clark",
"last_name": "Kent",
"is_active": true,
"is_superuser": true,
"is_staff": true,
"groups": [
],
"user_permissions": [],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "ckent@dailyplanet.com"
}
},
{
"pk": 2,
"model": "customuser.customuser",
"fields": {
"username": "jane",
"first_name": "Jane",
"last_name": "Smith",
"is_active": true,
"is_superuser": false,
"is_staff": true,
"groups": [
["Group 1"]
],
"user_permissions": [],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "jane@example.com"
}
},
{
"pk": 3,
"model": "customuser.customuser",
"fields": {
"username": "bob",
"first_name": "Bob",
"last_name": "Smith",
"is_active": true,
"is_superuser": false,
"is_staff": true,
"groups": [
["Group 2"]
],
"user_permissions": [],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "bob@example.com"
}
},
{
"pk": 4,
"model": "customuser.customuser",
"fields": {
"username": "sam",
"first_name": "Sam",
"last_name": "Smith",
"is_active": true,
"is_superuser": false,
"is_staff": true,
"groups": [
["Group 1"],
["Group 2"]
],
"user_permissions": [],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "sam@example.com"
}
},
{
"pk": 5,
"model": "customuser.customuser",
"fields": {
"username": "mary",
"first_name": "Mary",
"last_name": "Smith",
"is_active": true,
"is_superuser": false,
"is_staff": true,
"groups": [
],
"user_permissions": [
["access_admin", "wagtailadmin", "admin"]
],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "mary@example.com"
}
},
{
"pk": 6,
"model": "customuser.customuser",
"fields": {
"username": "josh",
"first_name": "Josh",
"last_name": "Smith",
"is_active": true,
"is_superuser": false,
"is_staff": true,
"groups": [
["Group 2"],
["Group 3"]
],
"user_permissions": [],
"password": "md5$seasalt$1e9bf2bf5606aa5c39852cc30f0f6f22",
"email": "josh@example.com"
}
}
]

Wyświetl plik

@ -29,10 +29,10 @@ else:
assignment_tag = register.assignment_tag
@register.inclusion_tag('wagtailadmin/shared/explorer_nav.html')
def explorer_nav():
@register.inclusion_tag('wagtailadmin/shared/explorer_nav.html', takes_context=True)
def explorer_nav(context):
return {
'nodes': get_navigation_menu_items()
'nodes': get_navigation_menu_items(context['request'].user)
}

Wyświetl plik

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import, unicode_literals
from django.core.urlresolvers import reverse
from django.test import TestCase
from wagtail.tests.utils import WagtailTestUtils
from wagtail.wagtailcore.models import Page
class TestExplorerNavView(TestCase, WagtailTestUtils):
"""
Test the way that the explorer nav menu behaves for users with different permissions.
This is isolated in its own test case because it requires a custom page tree and custom set of
users and groups.
The fixture sets up this page tree:
========================================================
ID Site Path
========================================================
1 /
2 testserver /home/
3 testserver /home/about-us/
4 example.com /home/
5 example.com /home/content/
6 example.com /home/content/page-1/
7 example.com /home/content/page-2/
9 example.com /home/content/page-2/child-1
8 example.com /home/other-content/
10 example.com /home-2/
========================================================
Group 1 has explore and choose permissions rooted at testserver's homepage.
Group 2 has explore and choose permissions rooted at exammple.com's page-1.
Group 3 has explore and choose permissions rooted at exammple.com's other-content.
User "jane" is in Group 1.
User "bob" is in Group 2.
User "sam" is in Groups 1 and 2.
User "josh" is in Groups 2 and 3.
User "mary" is is no Groups, but she has the "access wagtail admin" permission.
User "superman" is an admin.
Note that the Explorer Nav does not display leaf nodes.
"""
fixtures = ['test_explorable_pages.json']
def test_admins_see_all_pages(self):
self.assertTrue(self.client.login(username='superman', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 3)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=2))
self.assertEqual(response.context['nodes'][1][0], Page.objects.get(id=4))
self.assertEqual(response.context['nodes'][1][1][0][0], Page.objects.get(id=5))
self.assertEqual(response.context['nodes'][1][1][0][1][0][0], Page.objects.get(id=7))
# Even though example.com's Home 2 has no children, it's still displayed because it's at
# the top menu level for this user
self.assertEqual(response.context['nodes'][2][0], Page.objects.get(id=10))
def test_nav_root_for_nonadmin_is_closest_common_ancestor(self):
self.assertTrue(self.client.login(username='jane', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 1)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=2))
self.client.logout()
self.assertTrue(self.client.login(username='sam', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 2)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=2))
self.assertEqual(response.context['nodes'][1][0], Page.objects.get(id=4))
def test_nonadmin_sees_leaf_pages_at_root_level(self):
self.assertTrue(self.client.login(username='bob', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
# Bob's group's CCA is a leaf node, so by the naive "don't show childless pages" rule
# he would not be shown any nodes. This would be bad, so we make an exception whereby
# childless pages at the user's top level are shown
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 1)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=6))
self.assertEqual(len(response.context['nodes'][0][1]), 0)
def test_nonadmin_sees_pages_below_closest_common_ancestor(self):
self.assertTrue(self.client.login(username='josh', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
# Josh has permissions for /example-home/content/page-1 and /example-home/other-content ,
# of which the closest common ancestor is /example-home . However, since he doesn't need
# access to example-home itself, the menu begins at its children ('content' and
# 'other-content') instead
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 2)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=5))
# page-1 is childless, but user has direct permission on it, so it should be shown
self.assertEqual(len(response.context['nodes'][0][1]), 1)
self.assertEqual(response.context['nodes'][0][1][0][0], Page.objects.get(id=6))
self.assertEqual(len(response.context['nodes'][0][1][0][1]), 0)
self.assertEqual(response.context['nodes'][1][0], Page.objects.get(id=8))
self.assertEqual(len(response.context['nodes'][1][1]), 0)
def test_nonadmin_sees_only_explorable_pages(self):
self.assertTrue(self.client.login(username='sam', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
# Sam has permissions for /home and /example-home/content/page-1 , of which the closest
# common ancestor is root; we don't show root in the menu, so the top level will consist
# of 'home' and 'example-home' (but not the sibling 'home-2', which Sam doesn't have
# permission on)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 2)
# Sam should see the testserver homepage, the example.com homepage, and the Content page,
# but should not see Page 2.
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=2))
self.assertEqual(response.context['nodes'][1][0], Page.objects.get(id=4))
self.assertEqual(response.context['nodes'][1][1][0][0], Page.objects.get(id=5))
self.assertEqual(len(response.context['nodes'][1][1][0][1]), 1)
# page-1 is included in the menu, despite being a leaf node, because Sam has direct
# permission on it
self.assertEqual(response.context['nodes'][1][1][0][1][0][0], Page.objects.get(id=6))
self.client.logout()
self.assertTrue(self.client.login(username='jane', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(len(response.context['nodes']), 1)
self.assertEqual(response.context['nodes'][0][0], Page.objects.get(id=2))
self.assertEqual(len(response.context['nodes'][0][1]), 0)
def test_nonadmin_with_no_page_perms_sees_nothing_in_nav(self):
self.assertTrue(self.client.login(username='mary', password='password'))
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
self.assertEqual(response.status_code, 200)
# Being in no Groups, Mary should ot be shown any nodes.
self.assertEqual(len(response.context['nodes']), 0)

Wyświetl plik

@ -171,20 +171,6 @@ class TestSendMail(TestCase):
self.assertEqual(mail.outbox[0].from_email, "webmaster@localhost")
class TestExplorerNavView(TestCase, WagtailTestUtils):
def setUp(self):
self.homepage = Page.objects.get(id=2).specific
self.login()
def test_explorer_nav_view(self):
response = self.client.get(reverse('wagtailadmin_explorer_nav'))
# Check response
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('wagtailadmin/shared/explorer_nav.html')
self.assertEqual(response.context['nodes'][0][0], self.homepage)
class TestTagsAutocomplete(TestCase, WagtailTestUtils):
def setUp(self):
self.login()

Wyświetl plik

@ -31,7 +31,7 @@ def get_valid_next_url_from_request(request):
def explorer_nav(request):
return render(request, 'wagtailadmin/shared/explorer_nav.html', {
'nodes': get_navigation_menu_items(),
'nodes': get_navigation_menu_items(request.user),
})

Wyświetl plik

@ -1344,10 +1344,72 @@ class Page(six.with_metaclass(PageBase, MP_Node, index.Indexed, ClusterableModel
verbose_name_plural = _('pages')
def get_navigation_menu_items():
# Get all pages that appear in the navigation menu: ones which have children,
# or are at the top-level (this rule required so that an empty site out-of-the-box has a working menu)
pages = Page.objects.filter(Q(depth=2) | Q(numchild__gt=0)).order_by('path')
def get_navigation_menu_items(user):
# Get all pages that the user has direct add/edit/publish/lock permission on
if user.is_superuser:
# superuser has implicit permission on the root node
pages_with_direct_permission = Page.objects.filter(depth=1)
else:
pages_with_direct_permission = Page.objects.filter(
group_permissions__group__in=user.groups.all(),
group_permissions__permission_type__in=['add', 'edit', 'publish', 'lock']
)
if not(pages_with_direct_permission):
return []
# Find the closest common ancestor of the pages the user has permission for;
# this (or its children) will be the root menu level for this user.
cca_path = pages_with_direct_permission[0].path
for page in pages_with_direct_permission[1:]:
# repeatedly try shorter prefixes of page.path until we find one that cca_path starts with;
# this becomes the new cca_path
for path_len in range(len(page.path), 0, -Page.steplen):
path_to_test = page.path[0:path_len]
if cca_path.startswith(path_to_test):
cca_path = path_to_test
break
# Determine the depth (within the overall page tree) at which this user's menu starts:
# * if CCA is the root node, start at depth 2 (immediate children of root - because we
# never want to show root in the navigation);
# * else if CCA is a node they have direct permission for, start at that depth
# (so that they can edit that root node)
# * else start one level deeper (because the root node is only needed to provide navigation
# to deeper levels, and one level deeper is the first point where there's a choice to make)
if len(cca_path) == Page.steplen:
# CCA is the root node
menu_root_depth = 2
elif any(page.path == cca_path for page in pages_with_direct_permission):
# user has direct permission on the CCA node
menu_root_depth = int(len(cca_path) / Page.steplen)
else:
menu_root_depth = int(len(cca_path) / Page.steplen) + 1
# Run the query to fetch all pages to be shown in the navigation. This consists of the following
# set of pages, for each page in pages_with_direct_permission:
# * all ancestors (plus self) from menu_root_depth down
# * all descendants that have children
# * all descendants at menu_root_depth, regardless of whether they have children.
# (this ensures that a freshly built site with no child pages won't result in an empty menu)
ancestor_paths = [
page.path[0:path_len]
for page in pages_with_direct_permission
for path_len in range(menu_root_depth * Page.steplen, len(page.path) + Page.steplen, Page.steplen)
]
criteria = Q(path__in=ancestor_paths)
for page in pages_with_direct_permission:
criteria = criteria | (
Q(path__startswith=page.path) & (
Q(depth=menu_root_depth) | Q(numchild__gt=0)
)
)
pages = Page.objects.filter(criteria).order_by('path')
# Turn this into a tree structure:
# tree_node = (page, children)
@ -1358,7 +1420,9 @@ def get_navigation_menu_items():
# at depth d, its parent must be the last page we saw at depth (d-1), and so we can
# find it in that list.
depth_list = [(None, [])] # a dummy node for depth=0, since one doesn't exist in the DB
# create dummy entries for pages at a lower depth than menu_root_depth
# as these won't be covered by the 'pages' queryset
depth_list = [(None, []) for i in range(0, menu_root_depth)]
for page in pages:
# create a node for this page
@ -1375,14 +1439,7 @@ def get_navigation_menu_items():
# an exception here means that this node is one level deeper than any we've seen so far
depth_list.append(node)
# in Wagtail, the convention is to have one root node in the db (depth=1); the menu proper
# begins with the children of that node (depth=2).
try:
root, root_children = depth_list[1]
return root_children
except IndexError:
# what, we don't even have a root node? Fine, just return an empty list...
return []
return depth_list[menu_root_depth - 1][1]
@receiver(pre_delete, sender=Page)