diff --git a/pillar/web/nodes/routes.py b/pillar/web/nodes/routes.py index 1c8a5818..7bff2113 100644 --- a/pillar/web/nodes/routes.py +++ b/pillar/web/nodes/routes.py @@ -604,5 +604,94 @@ def url_for_node(node_id=None, node=None): return finders.find_url_for_node(node) +@blueprint.route("//breadcrumbs") +def breadcrumbs(node_id: str): + """Return breadcrumbs for the given node, as JSON. + + Note that a missing parent is still returned in the breadcrumbs, + but with `{_exists: false, name: '-unknown-'}`. + + The breadcrumbs start with the top-level parent, and end with the node + itself (marked by {_self: true}). Returns JSON like this: + + {breadcrumbs: [ + ..., + {_id: "parentID", + name: "The Parent Node", + node_type: "group", + url: "/p/project/parentID"}, + {_id: "deadbeefbeefbeefbeeffeee", + _self: true, + name: "The Node Itself", + node_type: "asset", + url: "/p/project/nodeID"}, + ]} + + When a parent node is missing, it has a breadcrumb like this: + + {_id: "deadbeefbeefbeefbeeffeee", + _exists': false, + name': '-unknown-'} + """ + + api = system_util.pillar_api() + is_self = True + + def make_crumb(some_node: None) -> dict: + """Construct a breadcrumb for this node.""" + nonlocal is_self + + crumb = { + '_id': some_node._id, + 'name': some_node.name, + 'node_type': some_node.node_type, + 'url': finders.find_url_for_node(some_node), + } + if is_self: + crumb['_self'] = True + is_self = False + return crumb + + def make_missing_crumb(some_node_id: None) -> dict: + """Construct 'missing parent' breadcrumb.""" + + return { + '_id': some_node_id, + '_exists': False, + 'name': '-unknown-', + } + + # The first node MUST exist. + try: + node = Node.find(node_id, api=api) + except ResourceNotFound: + log.warning('breadcrumbs(node_id=%r): Unable to find node', node_id) + raise wz_exceptions.NotFound(f'Unable to find node {node_id}') + except ForbiddenAccess: + log.warning('breadcrumbs(node_id=%r): access denied to current user', node_id) + raise wz_exceptions.Forbidden(f'No access to node {node_id}') + + crumbs = [] + while True: + crumbs.append(make_crumb(node)) + + child_id = node._id + node_id = node.parent + if not node_id: + break + + # If a subsequent node doesn't exist any more, include that in the breadcrumbs. + # Forbidden nodes are handled as if they don't exist. + try: + node = Node.find(node_id, api=api) + except (ResourceNotFound, ForbiddenAccess): + log.warning('breadcrumbs: Unable to find node %r but it is marked as parent of %r', + node_id, child_id) + crumbs.append(make_missing_crumb(node_id)) + break + + return jsonify({'breadcrumbs': list(reversed(crumbs))}) + + # Import of custom modules (using the same nodes decorator) from .custom import groups, storage, posts diff --git a/src/scripts/js/es6/common/events/Nodes.js b/src/scripts/js/es6/common/events/Nodes.js index 1af26120..cc0b7f63 100644 --- a/src/scripts/js/es6/common/events/Nodes.js +++ b/src/scripts/js/es6/common/events/Nodes.js @@ -25,6 +25,10 @@ class EventName { static deleted(nodeId) { return `pillar:node:${nodeId}:deleted`; } + + static loaded() { + return `pillar:node:loaded`; + } } function trigger(eventName, data) { @@ -139,6 +143,23 @@ class Nodes { EventName.deleted(nodeId), cb); } + + static triggerLoaded(nodeId) { + trigger(EventName.loaded(), {nodeId: nodeId}); + } + + /** + * Listen to events of nodes being loaded for display + * @param {Function(Event)} cb + */ + static onLoaded(cb) { + on(EventName.loaded(), cb); + } + + static offLoaded(cb) { + off(EventName.loaded(), cb); + } + } export { Nodes } diff --git a/src/scripts/js/es6/common/vuecomponents/breadcrumbs/Breadcrumbs.js b/src/scripts/js/es6/common/vuecomponents/breadcrumbs/Breadcrumbs.js new file mode 100644 index 00000000..51314662 --- /dev/null +++ b/src/scripts/js/es6/common/vuecomponents/breadcrumbs/Breadcrumbs.js @@ -0,0 +1,49 @@ +const TEMPLATE = ` + +` + +Vue.component("node-breadcrumbs", { + template: TEMPLATE, + created() { + this.loadBreadcrumbs(); + pillar.events.Nodes.onLoaded(event => { + this.nodeId = event.detail.nodeId; + }); + }, + props: { + nodeId: String, + }, + data() { return { + breadcrumbs: [], + }}, + watch: { + nodeId() { + this.loadBreadcrumbs(); + }, + }, + methods: { + loadBreadcrumbs() { + // The node ID may not exist (when at project level, for example). + if (!this.nodeId) { + this.breadcrumbs = []; + return; + } + + $.get(`/nodes/${this.nodeId}/breadcrumbs`) + .done(data => { + this.breadcrumbs = data.breadcrumbs; + }) + .fail(error => { + toastr.error(xhrErrorResponseMessage(error), "Unable to load breadcrumbs"); + }) + ; + }, + }, +}); diff --git a/src/scripts/js/es6/common/vuecomponents/init.js b/src/scripts/js/es6/common/vuecomponents/init.js index 86fd612f..26a30795 100644 --- a/src/scripts/js/es6/common/vuecomponents/init.js +++ b/src/scripts/js/es6/common/vuecomponents/init.js @@ -1,3 +1,4 @@ +import './breadcrumbs/Breadcrumbs' import './comments/CommentTree' import './customdirectives/click-outside' import { UnitOfWorkTracker } from './mixins/UnitOfWorkTracker' diff --git a/tests/test_web/test_nodes.py b/tests/test_web/test_nodes.py new file mode 100644 index 00000000..292ced59 --- /dev/null +++ b/tests/test_web/test_nodes.py @@ -0,0 +1,131 @@ +import typing + +from bson import ObjectId +import flask + +from pillar.tests import AbstractPillarTest + + +class BreadcrumbsTest(AbstractPillarTest): + def setUp(self, **kwargs): + super().setUp(**kwargs) + self.project_id, self.project = self.ensure_project_exists() + + def _create_group(self, + parent_id: typing.Optional[ObjectId], + name: str) -> ObjectId: + node = { + 'name': name, + 'description': '', + 'node_type': 'group', + 'user': self.project['user'], + 'properties': {'status': 'published'}, + 'project': self.project_id, + } + if parent_id: + node['parent'] = parent_id + return self.create_node(node) + + def test_happy(self) -> ObjectId: + # Create the nodes we expect to be returned in the breadcrumbs. + top_group_node_id = self._create_group(None, 'Top-level node') + group_node_id = self._create_group(top_group_node_id, 'Group node') + + fid, _ = self.ensure_file_exists() + node_id = self.create_node({ + 'name': 'Asset node', + 'parent': group_node_id, + 'description': '', + 'node_type': 'asset', + 'user': self.project['user'], + 'properties': {'status': 'published', 'file': fid}, + 'project': self.project_id, + }) + + # Create some siblings that should not be returned. + self._create_group(None, 'Sibling of top node') + self._create_group(top_group_node_id, 'Sibling of group node') + self._create_group(group_node_id, 'Sibling of asset node') + + expected = {'breadcrumbs': [ + {'_id': str(top_group_node_id), + 'name': 'Top-level node', + 'node_type': 'group', + 'url': f'/p/{self.project["url"]}/{top_group_node_id}'}, + {'_id': str(group_node_id), + 'name': 'Group node', + 'node_type': 'group', + 'url': f'/p/{self.project["url"]}/{group_node_id}'}, + {'_id': str(node_id), + '_self': True, + 'name': 'Asset node', + 'node_type': 'asset', + 'url': f'/p/{self.project["url"]}/{node_id}'}, + ]} + + with self.app.app_context(): + url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id)) + + actual = self.get(url).json + self.assertEqual(expected, actual) + + return node_id + + def test_missing_parent(self): + # Note that this group node doesn't exist in the database: + group_node_id = ObjectId(3 * 'deadbeef') + + fid, _ = self.ensure_file_exists() + node_id = self.create_node({ + 'name': 'Asset node', + 'parent': group_node_id, + 'description': '', + 'node_type': 'asset', + 'user': self.project['user'], + 'properties': {'status': 'published', 'file': fid}, + 'project': self.project_id, + }) + + expected = {'breadcrumbs': [ + {'_id': str(group_node_id), + '_exists': False, + 'name': '-unknown-'}, + {'_id': str(node_id), + '_self': True, + 'name': 'Asset node', + 'node_type': 'asset', + 'url': f'/p/{self.project["url"]}/{node_id}'}, + ]} + + with self.app.app_context(): + url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id)) + + actual = self.get(url).json + self.assertEqual(expected, actual) + + def test_missing_node(self): + with self.app.app_context(): + url = flask.url_for('nodes.breadcrumbs', node_id=3 * 'deadbeef') + self.get(url, expected_status=404) + + def test_permissions(self): + # Use the same test case as the happy case. + node_id = self.test_happy() + + # Tweak the project to make it private. + with self.app.app_context(): + proj_coll = self.app.db('projects') + result = proj_coll.update_one({'_id': self.project_id}, + {'$set': {'permissions.world': []}}) + self.assertEqual(1, result.modified_count) + self.project = self.fetch_project_from_db(self.project_id) + + with self.app.app_context(): + url = flask.url_for('nodes.breadcrumbs', node_id=str(node_id)) + + # Anonymous access should be forbidden. + self.get(url, expected_status=403) + + # Authorized access should work, though. + self.create_valid_auth_token(self.project['user'], token='user-token') + self.get(url, auth_token='user-token')