Merge pull request #11259 from OlegZharkov/manage-folder-rewrite

Manage folder rewrite in Vue & VueRouter
This commit is contained in:
Dannon
2021-02-09 22:09:16 -05:00
committed by GitHub
13 changed files with 445 additions and 283 deletions
+2 -1
View File
@@ -82,7 +82,8 @@
"vueisotope": "^3.1.2",
"vuex": "^3.4.0",
"vuex-cache": "^3.2.0",
"vuex-persist": "^3.1.3"
"vuex-persist": "^3.1.3",
"vue-observe-visibility": "^1.0.0"
},
"scripts": {
"watch": "gulp && yarn run save-build-hash && yarn run webpack-watch",
@@ -216,7 +216,7 @@
</b-button>
<b-button
v-if="row.item.can_manage && !row.item.deleted"
:href="createPermissionLink(row.item)"
@click="navigateToPermission(row.item)"
data-toggle="tooltip"
data-placement="top"
size="sm"
@@ -460,9 +460,9 @@ export default {
return `${this.root}library/list#folders/${this.current_folder_id}/datasets/${element.id}`;
else if (element.type === "folder") return `${this.root}library/folders/${element.id}`;
},
createPermissionLink(element) {
if (element.type === "file") return `${this.createContentLink(element)}/permissions`;
else if (element.type === "folder") return `${this.root}library/list#folders/${element.id}/permissions`;
navigateToPermission(element) {
if (element.type === "file") window.location.replace(`${this.createContentLink(element)}/permissions`);
else if (element.type === "folder") this.$router.push({ path: `/permissions/${element.id}` });
},
getMessage(element) {
if (element.type === "file") return element.message;
@@ -0,0 +1,207 @@
<template>
<div>
<div>
<div v-if="folder">
<b-button variant="primary" :to="{ path: `/${this.folder.parent_id}` }">
<font-awesome-icon icon="angle-double-left">1</font-awesome-icon>
</b-button>
<div>
<div class="header text-center">{{ folder.name }}</div>
</div>
</div>
<div>
<div class="text-center">
<b-alert show variant="warning" v-if="is_admin">
You are logged in as an <strong>administrator</strong> therefore you can manage any folder on
this Galaxy instance. Please make sure you understand the consequences.
</b-alert>
<b-alert show variant="warning" v-else>
You can assign any number of roles to any of the following permission types. However please read
carefully the implications of such actions.
</b-alert>
</div>
</div>
<hr class="my-4" />
<b-container fluid>
<div class="dataset_table">
<h2>Folder permissions</h2>
<h4>
Roles that can manage permissions on this folder
</h4>
<b-row>
<b-col>
<PermissionsInputField
v-if="manage_folder_role_list"
:folder_id="folder_id"
:permission_type="manage_type"
:initial_value="manage_folder_role_list"
@input="setUserPermissionsPreferences"
/></b-col>
<b-col>
<b-alert show variant="info">
User with <strong>any</strong> of these roles can manage permissions on this folder.
</b-alert>
</b-col>
</b-row>
<h4>
Roles that can add items to this folder
</h4>
<b-row>
<b-col>
<PermissionsInputField
v-if="add_library_item_role_list"
:folder_id="folder_id"
:permission_type="add_type"
:initial_value="add_library_item_role_list"
@input="setUserPermissionsPreferences"
/></b-col>
<b-col>
<b-alert class="p-3" show variant="info">
User with <strong>any</strong> of these roles can add items to this folder (folders and
datasets).
</b-alert>
</b-col>
</b-row>
<h4>
Roles that can modify this folder
</h4>
<b-row>
<b-col>
<PermissionsInputField
v-if="modify_folder_role_list"
:folder_id="folder_id"
:permission_type="modify_type"
:initial_value="modify_folder_role_list"
@input="setUserPermissionsPreferences"
/></b-col>
<b-col>
<b-alert show variant="info">
User with <strong>any</strong> of these roles can modify this folder (name, etc.).
</b-alert></b-col
>
</b-row>
<button
data-toggle="tooltip"
data-placement="top"
title="Save modifications"
class="btn btn-secondary toolbtn_save_permissions primary-button"
type="button"
@click="postPermissions"
>
<font-awesome-icon :icon="['far', 'save']" />
&nbsp;Save
</button>
</div>
</b-container>
</div>
</div>
</template>
<script>
import Vue from "vue";
import { getAppRoot } from "onload/loadConfig";
import BootstrapVue from "bootstrap-vue";
import { Services } from "./services";
import { Toast } from "ui/toast";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { getGalaxyInstance } from "app";
import PermissionsInputField from "./PermissionsInputField.vue";
import { initManageFolderIcons } from "components/LibraryFolder/icons";
import "vue-multiselect/dist/vue-multiselect.min.css";
import VueObserveVisibility from "vue-observe-visibility";
Vue.use(VueObserveVisibility);
Vue.use(BootstrapVue);
initManageFolderIcons();
export default {
props: {
folder_id: {
type: String,
required: true,
},
},
components: {
PermissionsInputField,
FontAwesomeIcon,
},
data() {
return {
permissions: undefined,
folder: undefined,
is_admin: undefined,
add_library_item_role_list: undefined,
modify_folder_role_list: undefined,
manage_folder_role_list: undefined,
add_type: "add_type",
manage_type: "manage_type",
modify_type: "modify_type",
};
},
created() {
const Galaxy = getGalaxyInstance();
this.root = getAppRoot();
this.services = new Services({ root: this.root });
this.is_admin = Galaxy.user.attributes.is_admin;
this.services.getFolderPermissions(this.folder_id).then((fetched_permissions) => {
this.add_library_item_role_list = this._serializeRoles(fetched_permissions.add_library_item_role_list);
this.manage_folder_role_list = this._serializeRoles(fetched_permissions.manage_folder_role_list);
this.modify_folder_role_list = this._serializeRoles(fetched_permissions.modify_folder_role_list);
});
this.services.getFolder(this.folder_id).then((response) => {
this.folder = response;
});
},
methods: {
getParentLink() {
return `${this.root}library/folders/${this.folder.parent_id}`;
},
setUserPermissionsPreferences(ids, permission_type) {
switch (permission_type) {
case "manage_type":
this.manage_folder_role_list = ids;
break;
case "add_type":
this.add_library_item_role_list = ids;
break;
case "modify_type":
this.modify_folder_role_list = ids;
break;
}
},
postPermissions() {
this.services.setPermissions(
this.folder_id,
this.add_library_item_role_list,
this.manage_folder_role_list,
this.modify_folder_role_list,
(fetched_permissions) => {
Toast.success("Permissions saved.");
this.permissions = fetched_permissions;
},
(error) => {
Toast.error("An error occurred while attempting to set folder permissions.");
console.error(error);
}
);
},
_serializeRoles: function (role_list) {
const selected_roles = [];
role_list.forEach((item) => {
selected_roles.push({ name: item[0], id: item[1] });
});
return selected_roles;
},
},
};
</script>
<style scoped>
.header {
font-size: 45px;
}
</style>
@@ -0,0 +1,102 @@
<template>
<div v-if="options && value">
<multiselect
v-model="value"
:options="fetched_options"
:clear-on-select="true"
:preserve-search="true"
:multiple="true"
label="name"
track-by="id"
@input="valueChanged"
@search-change="searchChanged"
:internal-search="false"
>
<template slot="afterList">
<div v-observe-visibility="reachedEndOfList" v-if="hasMorePages">
<span class="spinner fa fa-spinner fa-spin fa-1x" />
</div>
</template>
</multiselect>
</div>
</template>
<script>
import Multiselect from "vue-multiselect";
import { Services } from "./services";
export default {
props: {
folder_id: {
type: String,
required: true,
},
permission_type: {
type: String,
required: true,
},
initial_value: {
type: Array,
required: true,
},
},
components: {
Multiselect,
},
data() {
return {
permissions: undefined,
folder: undefined,
is_admin: undefined,
options: undefined,
value: undefined,
page: 1,
page_limit: 10,
searchValue: "",
fetched_options: [],
};
},
created() {
this.services = new Services({ root: this.root });
// Avoid mutating a prop directly
this.value = this.initial_value;
this.getSelectOptions();
},
computed: {
hasMorePages() {
return this.page * this.page_limit < this.options.total;
},
},
methods: {
getSelectOptions(searchChanged = false) {
this.services
.getSelectOptions(this.folder_id, true, this.page, this.page_limit, this.searchValue)
.then((response) => {
this.options = response;
if (searchChanged) this.fetched_options = this.options.roles;
else this.fetched_options = this.fetched_options.concat(this.options.roles);
});
},
reachedEndOfList(reached) {
if (reached) {
this.page++;
this.getSelectOptions();
}
},
valueChanged() {
this.$emit("input", this.value, this.permission_type);
},
searchChanged(searchValue) {
this.page = 1;
this.searchValue = searchValue;
this.getSelectOptions(true);
},
},
};
</script>
<style scoped>
.spinner {
text-align: center;
}
</style>
@@ -0,0 +1,66 @@
import axios from "axios";
import { rethrowSimple } from "utils/simple-error";
import { getAppRoot } from "onload/loadConfig";
export class Services {
constructor(options = {}) {
this.root = options.root || getAppRoot();
}
async getFolderPermissions(id) {
const url = `${this.root}api/folders/${id}/permissions?scope=current`;
try {
const response = await axios.get(url);
return response.data;
} catch (e) {
rethrowSimple(e);
}
}
async getFolder(id) {
const url = `${this.root}api/folders/${id}`;
try {
const response = await axios.get(url);
return response.data;
} catch (e) {
rethrowSimple(e);
}
}
async getSelectOptions(id, is_library_access, page, page_limit, searchQuery) {
searchQuery = searchQuery ? `&q=${searchQuery}` : "";
const url = `${this.root}api/folders/${id}/permissions?scope=available&is_library_access=${is_library_access}&page_limit=${page_limit}&page=${page}${searchQuery}`;
try {
const response = await axios.get(url);
return response.data;
} catch (e) {
rethrowSimple(e);
}
}
async setPermissions(id, add_ids, manage_ids, modify_ids, onSuccess, onError) {
var formData = new FormData();
formData.append(
"add_ids[]",
add_ids.map((a) => a.id)
);
formData.append(
"manage_ids[]",
manage_ids.map((a) => a.id)
);
formData.append(
"modify_ids[]",
modify_ids.map((a) => a.id)
);
axios({
method: "post",
url: `${getAppRoot()}api/folders/${id}/permissions?action=set_permissions`,
data: formData,
headers: { "Content-Type": "multipart/form-data" },
})
.then(function (response) {
onSuccess(response);
})
.catch((response) => {
onError(response);
});
}
}
@@ -0,0 +1,28 @@
<script>
import { getAppRoot } from "onload/loadConfig";
import Vue from "vue";
import VueRouter from "vue-router";
import LibraryFolderPermissions from "./LibraryFolderPermissions/LibraryFolderPermissions.vue";
import LibraryFolder from "components/LibraryFolder/LibraryFolder.vue";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
base: `${getAppRoot()}library/folders`,
routes: [
{
path: "/:folder_id",
name: "LibraryFolder",
component: LibraryFolder,
props: true,
},
{
path: "/permissions/:folder_id",
name: "LibraryFolder",
component: LibraryFolderPermissions,
props: true,
},
],
});
</script>
@@ -16,6 +16,7 @@ import {
faDownload,
faBook,
faMinusSquare,
faAngleDoubleLeft,
} from "@fortawesome/free-solid-svg-icons";
import { library } from "@fortawesome/fontawesome-svg-core";
@@ -36,6 +37,8 @@ const tableIcons = [
faSquare,
faMinusSquare,
];
const manageIcons = [faAngleDoubleLeft, faSave];
const topBarIcons = [faHome, faPlus, faInfoCircle, faTrash, faDownload, faBook];
export function initFolderTableIcons() {
@@ -44,6 +47,12 @@ export function initFolderTableIcons() {
});
}
export function initManageFolderIcons() {
manageIcons.forEach((icon) => {
library.add(icon);
});
}
export function initTopBarIcons() {
topBarIcons.forEach((icon) => {
library.add(icon);
+18 -4
View File
@@ -29,7 +29,6 @@ import WorkflowImport from "components/Workflow/WorkflowImport.vue";
import TrsImport from "components/Workflow/TrsImport.vue";
import TrsSearch from "components/Workflow/TrsSearch.vue";
import InteractiveTools from "components/InteractiveTools/InteractiveTools.vue";
import LibraryFolder from "components/LibraryFolder/LibraryFolder.vue";
import WorkflowList from "components/Workflow/WorkflowList.vue";
import HistoryImport from "components/HistoryImport.vue";
import { HistoryExport } from "components/HistoryExport/index";
@@ -49,12 +48,14 @@ import DisplayStructure from "components/DisplayStructured.vue";
import { CloudAuth } from "components/User/CloudAuth";
import { ExternalIdentities } from "components/User/ExternalIdentities";
import Confirmation from "components/login/Confirmation.vue";
import LibraryFolderRouter from "components/LibraryFolder/LibraryFolderRouter.vue";
import Vue from "vue";
import store from "store";
import VueRouterMain from "./VueRouterMain.vue";
/** Routes */
export const getAnalysisRouter = (Galaxy) =>
Router.extend({
export const getAnalysisRouter = (Galaxy) => {
return Router.extend({
routes: {
"(/)(#)(_=_)": "home",
"(/)root*": "home",
@@ -99,6 +100,7 @@ export const getAnalysisRouter = (Galaxy) =>
"(/)datasets/error": "show_dataset_error",
"(/)interactivetool_entry_points(/)list": "show_interactivetool_list",
"(/)library/folders(/)(:folder_id)": "show_library_folder",
"(/)library/folders/permissions(/)(:folder_id)": "show_library_folder",
},
require_login: ["show_user", "show_user_form", "show_workflows", "show_cloud_auth", "show_external_ids"],
@@ -117,6 +119,17 @@ export const getAnalysisRouter = (Galaxy) =>
this.page.display(container, noPadding);
new instance({ store, propsData }).$mount(container);
},
_display_vue_router: function (router, propsData = {}, active_tab = null, noPadding = false) {
const container = document.createElement("div");
if (active_tab) {
container.active_tab = active_tab;
}
this.page.display(container, noPadding);
new Vue({
router: router,
render: (h) => h(VueRouterMain),
}).$mount(container);
},
show_tours: function (tour_id) {
if (tour_id) {
@@ -152,7 +165,7 @@ export const getAnalysisRouter = (Galaxy) =>
show_library_folder: function (folder_id) {
this.page.toolPanel?.component.hide(0);
this.page.panels.right.hide();
this._display_vue_helper(LibraryFolder, { folder_id: folder_id });
this._display_vue_router(LibraryFolderRouter, { folder_id: folder_id });
},
show_cloud_auth: function () {
@@ -457,3 +470,4 @@ export const getAnalysisRouter = (Galaxy) =>
this._display_vue_helper(WorkflowRun, props, "workflow");
},
});
};
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
-11
View File
@@ -13,7 +13,6 @@ import mod_librarylist_view from "mvc/library/library-librarylist-view";
import mod_librarytoolbar_view from "mvc/library/library-librarytoolbar-view";
import mod_library_dataset_view from "mvc/library/library-dataset-view";
import mod_library_library_view from "mvc/library/library-library-view";
import mod_library_folder_view from "mvc/library/library-folder-view";
// ============================================================================
/**
* The Data Libraries router. Takes care about triggering routes
@@ -188,16 +187,6 @@ var GalaxyLibrary = Backbone.View.extend({
});
});
this.library_router.on("route:folder_permissions", (folder_id) => {
if (Galaxy.libraries.folderView) {
Galaxy.libraries.folderView.$el.unbind("click");
}
Galaxy.libraries.folderView = new mod_library_folder_view.FolderView({
id: folder_id,
show_permissions: true,
});
});
Backbone.history.start({ pushState: false });
},
});
@@ -1,263 +0,0 @@
import _ from "underscore";
import $ from "jquery";
import Backbone from "backbone";
import { getGalaxyInstance } from "app";
import { getAppRoot } from "onload/loadConfig";
import { Toast } from "ui/toast";
import mod_library_model from "mvc/library/library-model";
import mod_select from "mvc/ui/ui-select";
var FolderView = Backbone.View.extend({
el: "#center",
model: null,
options: {},
events: {
"click .toolbtn_save_permissions": "savePermissions",
},
initialize: function (options) {
this.options = _.extend(this.options, options);
if (this.options.id) {
this.fetchFolder();
}
},
fetchFolder: function (options) {
const Galaxy = getGalaxyInstance();
this.options = _.extend(this.options, options);
this.model = new mod_library_model.FolderAsModel({
id: this.options.id,
});
var that = this;
this.model.fetch({
success: function () {
if (that.options.show_permissions) {
that.showPermissions();
}
},
error: function (model, response) {
if (typeof response.responseJSON !== "undefined") {
Toast.error(`${response.responseJSON.err_msg} Click this to go back.`, "", {
onclick: function () {
Galaxy.libraries.library_router.back();
},
});
} else {
Toast.error("An error occurred. Click this to go back.", "", {
onclick: function () {
Galaxy.libraries.library_router.back();
},
});
}
},
});
},
showPermissions: function (options) {
const Galaxy = getGalaxyInstance();
this.options = _.extend(this.options, options);
$(".tooltip").remove();
var is_admin = false;
if (Galaxy.user) {
is_admin = Galaxy.user.isAdmin();
}
var template = this.templateFolderPermissions();
this.$el.html(template({ folder: this.model, is_admin: is_admin, root_path: getAppRoot() }));
var self = this;
$.get(`${getAppRoot()}api/folders/${self.id}/permissions?scope=current`)
.done((fetched_permissions) => {
self.prepareSelectBoxes({
fetched_permissions: fetched_permissions,
});
})
.fail(() => {
Toast.error("An error occurred while attempting to fetch folder permissions.");
});
$('#center [data-toggle="tooltip"]').tooltip({ trigger: "hover" });
//hack to show scrollbars
$("#center").css("overflow", "auto");
},
_serializeRoles: function (role_list) {
var selected_roles = [];
for (var i = 0; i < role_list.length; i++) {
selected_roles.push(`${role_list[i][1]}:${role_list[i][0]}`);
}
return selected_roles;
},
prepareSelectBoxes: function (options) {
this.options = _.extend(this.options, options);
var fetched_permissions = this.options.fetched_permissions;
var self = this;
var selected_add_item_roles = this._serializeRoles(fetched_permissions.add_library_item_role_list);
var selected_manage_folder_roles = this._serializeRoles(fetched_permissions.manage_folder_role_list);
var selected_modify_folder_roles = this._serializeRoles(fetched_permissions.modify_folder_role_list);
self.addSelectObject = new mod_select.View(
this._createSelectOptions(this, "add_perm", selected_add_item_roles, false)
);
self.manageSelectObject = new mod_select.View(
this._createSelectOptions(this, "manage_perm", selected_manage_folder_roles, false)
);
self.modifySelectObject = new mod_select.View(
this._createSelectOptions(this, "modify_perm", selected_modify_folder_roles, false)
);
},
_createSelectOptions: function (self, id, init_data) {
var select_options = {
minimumInputLength: 0,
css: id,
multiple: true,
placeholder: "Click to select a role",
container: self.$el.find(`#${id}`),
ajax: {
url: `${getAppRoot()}api/folders/${self.id}/permissions?scope=available`,
dataType: "json",
quietMillis: 100,
data: function (term, page) {
// page is the one-based page number tracked by Select2
return {
q: term, //search term
page_limit: 10, // page size
page: page, // page number
};
},
results: function (data, page) {
var more = page * 10 < data.total; // whether or not there are more results available
// notice we return the value of more so Select2 knows if more results can be loaded
return { results: data.roles, more: more };
},
},
formatResult: function roleFormatResult(role) {
return `${role.name} type: ${role.type}`;
},
formatSelection: function roleFormatSelection(role) {
return role.name;
},
initSelection: function (element, callback) {
// the input tag has a value attribute preloaded that points to a preselected role's id
// this function resolves that id attribute to an object that select2 can render
// using its formatResult renderer - that way the role name is shown preselected
var data = [];
$(element.val().split(",")).each(function () {
var item = this.split(":");
data.push({
id: item[0],
name: item[1],
});
});
callback(data);
},
initialData: init_data.join(","),
dropdownCssClass: "bigdrop", // apply css that makes the dropdown taller
};
return select_options;
},
/**
* Extract the role ids from Select2 elements's 'data'
*/
_extractIds: function (roles_list) {
var ids_list = [];
for (var i = roles_list.length - 1; i >= 0; i--) {
ids_list.push(roles_list[i].id);
}
return ids_list;
},
/**
* Save the permissions for roles entered in the select boxes.
*/
savePermissions: function (event) {
var self = this;
var add_ids = this._extractIds(this.addSelectObject.$el.select2("data"));
var manage_ids = this._extractIds(this.manageSelectObject.$el.select2("data"));
var modify_ids = this._extractIds(this.modifySelectObject.$el.select2("data"));
$.post(`${getAppRoot()}api/folders/${self.id}/permissions?action=set_permissions`, {
"add_ids[]": add_ids,
"manage_ids[]": manage_ids,
"modify_ids[]": modify_ids,
})
.done((fetched_permissions) => {
self.showPermissions({
fetched_permissions: fetched_permissions,
});
Toast.success("Permissions saved.");
})
.fail(() => {
Toast.error("An error occurred while attempting to set folder permissions.");
});
},
templateFolderPermissions: function () {
return _.template(
`<div class="library_style_container">
<div>
<a href="<%- root_path %>library/folders/<%= folder.get("parent_id") %>">
<button data-toggle="tooltip" data-placement="top" title="Go back to the parent folder"
class="btn btn-secondary primary-button" type="button">
<span class="fa fa-caret-left fa-lg"/>
&nbsp;Parent folder
</button>
</a>
</div>
<h1>
Folder: <%= _.escape(folder.get("name")) %>
</h1>
<div class="alert alert-warning">
<% if (is_admin) { %>
You are logged in as an <strong>administrator</strong> therefore you can manage any
folder on this Galaxy instance. Please make sure you understand the consequences.
<% } else { %>
You can assign any number of roles to any of the following permission types.
However please read carefully the implications of such actions.
<% }%>
</div>
<div class="dataset_table">
<h2>Folder permissions</h2>
<h4>
Roles that can manage permissions on this folder
</h4>
<div id="manage_perm" class="manage_perm roles-selection"></div>
<div class="alert alert-info roles-selection">
User with <strong>any</strong> of these roles can manage permissions on this folder.
</div>
<h4>
Roles that can add items to this folder
</h4>
<div id="add_perm" class="add_perm roles-selection"></div>
<div class="alert alert-info roles-selection">
User with <strong>any</strong> of these roles can add items to this folder (folders and datasets).
</div>
<h4>
Roles that can modify this folder
</h4>
<div id="modify_perm" class="modify_perm roles-selection"></div>
<div class="alert alert-info roles-selection">
User with <strong>any</strong> of these roles can modify this folder (name, etc.).
</div>
<button data-toggle="tooltip" data-placement="top" title="Save modifications"
class="btn btn-secondary toolbtn_save_permissions primary-button" type="button">
<span class="fa fa-floppy-o"/>
&nbsp;Save
</button>
</div>
</div>`
);
},
});
export default {
FolderView: FolderView,
};
+5
View File
@@ -15291,6 +15291,11 @@ vue-multiselect@^2.1.0:
resolved "https://registry.yarnpkg.com/vue-multiselect/-/vue-multiselect-2.1.6.tgz#5be5d811a224804a15c43a4edbb7485028a89c7f"
integrity sha512-s7jmZPlm9FeueJg1RwJtnE9KNPtME/7C8uRWSfp9/yEN4M8XcS/d+bddoyVwVnvFyRh9msFo0HWeW0vTL8Qv+w==
vue-observe-visibility@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-1.0.0.tgz#17cf1b2caf74022f0f3c95371468ddf2b9573152"
integrity sha512-s5TFh3s3h3Mhd3jaz3zGzkVHKHnc/0C/gNr30olO99+yw2hl3WBhK3ng3/f9OF+qkW4+l7GkmwfAzDAcY3lCFg==
vue-router@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.3.1.tgz#7d0545dd8ebf4f45494ca185b7eccb38d67689c1"
+1
View File
@@ -192,6 +192,7 @@ def app_pair(global_conf, load_app_kwds=None, wsgi_preflight=True, **kwargs):
webapp.add_client_route('/custom_builds')
webapp.add_client_route('/interactivetool_entry_points/list')
webapp.add_client_route('/library/folders/{folder_id}')
webapp.add_client_route('/library/folders/permissions/{folder_id}')
# ==== Done
# Indicate that all configuration settings have been provided