From 341efc0e4b43bc779168d5e23b4793e8f05d899a Mon Sep 17 00:00:00 2001 From: vera-liu Date: Thu, 29 Sep 2016 14:48:13 -0700 Subject: [PATCH 1/3] Explore control panel - Chart control, TimeFilter, GroupBy, Filters (#1205) * create structure for new forked explore view (#1099) * create structure for new forked explore view * update component name * add bootstrap data pattern * remove console.log * Associate version to entry files (#1060) * Associate version to entry files * Modified path joins for configs * Made changes based on comments * Created store and reducers (#1108) * Created store and reducers * Added spec * Modifications based on comments * Explore control panel components: Chart control, Time filter, SQL, GroupBy and Filters * Modifications based on comments --- caravel/templates/caravel/explore.html | 302 +------------------------ caravel/views.py | 17 +- 2 files changed, 17 insertions(+), 302 deletions(-) diff --git a/caravel/templates/caravel/explore.html b/caravel/templates/caravel/explore.html index 1ea84c5de88d..57e0c3a4c7fa 100644 --- a/caravel/templates/caravel/explore.html +++ b/caravel/templates/caravel/explore.html @@ -1,307 +1,15 @@ {% extends "caravel/basic.html" %} -{% block title %} - {% if slice %} - [slice] {{ slice.slice_name }} - {% else %} - [explore] {{ viz.datasource.table_name }} - {% endif %} -{% endblock %} - {% block body %} - {% set datasource = viz.datasource %} - {% set form = viz.form %} - - {% macro panofield(fieldname)%} -
- {% set field = form.get_field(fieldname)%} -
- {{ field.label }} - {% if field.description %} - - {% endif %} - {{ field(class_=form.field_css_classes(field.name)) }} -
-
- {% endmacro %} - -
- +
{% endblock %} {% block tail_js %} {{ super() }} - {% with filename="explore" %} + {% with filename="explorev2" %} {% include "caravel/partials/_script_tag.html" %} {% endwith %} {% endblock %} diff --git a/caravel/views.py b/caravel/views.py index 18fdb32be70d..42bec0b73fe8 100755 --- a/caravel/views.py +++ b/caravel/views.py @@ -1199,11 +1199,18 @@ def explore(self, datasource_type, datasource_id, slice_id=None): template = "caravel/standalone.html" else: template = "caravel/explore.html" - return self.render_template( - template, viz=viz_obj, slice=slc, datasources=datasources, - can_add=slice_add_perm, can_edit=slice_edit_perm, - can_download=slice_download_perm, - userid=g.user.get_id() if g.user else '') + bootstrap_data = { + "can_add": slice_add_perm, + "can_download": slice_download_perm, + "can_edit": slice_edit_perm, + # TODO: separate endpoint for fetching datasources + "datasources": [(d.id, d.full_name) for d in datasources], + "datasource_id": datasource_id, + "datasource_type": datasource_type, + "user_id": g.user.get_id() if g.user else None, + "viz": json.loads(viz_obj.get_json()) + } + return self.render_template(template, bootstrap_data=json.dumps(bootstrap_data)) @has_access @expose("/exploreV2////") From 0ad916fe5472666d22638d425dbe4bd369e84f11 Mon Sep 17 00:00:00 2001 From: Vera Liu Date: Mon, 3 Oct 2016 09:01:30 -0700 Subject: [PATCH 2/3] Added tests --- .../explorev2/components/ChartControl.jsx | 2 +- .../explorev2/components/Filters.jsx | 3 +- .../explorev2/components/GroupBy.jsx | 2 +- .../explorev2/components/SqlClause.jsx | 2 +- .../explorev2/components/TimeFilter.jsx | 2 +- caravel/assets/package.json | 2 + .../explore/components/ChartControl_spec.jsx | 20 +++ .../explore/components/Filter_spec.jsx | 45 ++++++ .../explore/components/GroupBy_spec.js | 17 +++ .../explore/components/SqlClause_spec.js | 16 ++ .../explore/components/TimeFilter_spec.jsx | 17 +++ .../explore/components/actions_spec.js | 142 +++++++++++++++++- caravel/views.py | 3 + tests/core_tests.py | 13 ++ 14 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx create mode 100644 caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx create mode 100644 caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js create mode 100644 caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js create mode 100644 caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx diff --git a/caravel/assets/javascripts/explorev2/components/ChartControl.jsx b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx index eee6e0d6ff45..b03c2d4fab6b 100644 --- a/caravel/assets/javascripts/explorev2/components/ChartControl.jsx +++ b/caravel/assets/javascripts/explorev2/components/ChartControl.jsx @@ -20,7 +20,7 @@ const defaultProps = { vizType: null, }; -class ChartControl extends React.Component { +export class ChartControl extends React.Component { componentWillMount() { if (this.props.datasourceId) { this.props.actions.setFormOpts(this.props.datasourceId, this.props.datasourceType); diff --git a/caravel/assets/javascripts/explorev2/components/Filters.jsx b/caravel/assets/javascripts/explorev2/components/Filters.jsx index 49e384157ff6..072aa99cc944 100644 --- a/caravel/assets/javascripts/explorev2/components/Filters.jsx +++ b/caravel/assets/javascripts/explorev2/components/Filters.jsx @@ -1,5 +1,4 @@ import React from 'react'; -// import { Tab, Row, Col, Nav, NavItem } from 'react-bootstrap'; import Select from 'react-select'; import { Button } from 'react-bootstrap'; import { connect } from 'react-redux'; @@ -18,7 +17,7 @@ const defaultProps = { filters: [], }; -class Filters extends React.Component { +export class Filters extends React.Component { constructor(props) { super(props); this.state = { diff --git a/caravel/assets/javascripts/explorev2/components/GroupBy.jsx b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx index cbf10fdfe59e..6467e73b686f 100644 --- a/caravel/assets/javascripts/explorev2/components/GroupBy.jsx +++ b/caravel/assets/javascripts/explorev2/components/GroupBy.jsx @@ -19,7 +19,7 @@ const defaultProps = { groupByColumns: [], }; -class GroupBy extends React.Component { +export class GroupBy extends React.Component { changeColumns(groupByColumnOpts) { this.props.actions.setGroupByColumns(groupByColumnOpts); } diff --git a/caravel/assets/javascripts/explorev2/components/SqlClause.jsx b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx index ab484dfe20ab..76630474ab27 100644 --- a/caravel/assets/javascripts/explorev2/components/SqlClause.jsx +++ b/caravel/assets/javascripts/explorev2/components/SqlClause.jsx @@ -7,7 +7,7 @@ const propTypes = { actions: React.PropTypes.object, }; -class SqlClause extends React.Component { +export class SqlClause extends React.Component { changeWhere(whereClause) { this.props.actions.setWhereClause(whereClause); } diff --git a/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx index dfcbd4041d84..1afe89b6bd09 100644 --- a/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx +++ b/caravel/assets/javascripts/explorev2/components/TimeFilter.jsx @@ -25,7 +25,7 @@ const defaultProps = { until: null, }; -class TimeFilter extends React.Component { +export class TimeFilter extends React.Component { changeTimeColumn(timeColumnOpt) { const val = (timeColumnOpt) ? timeColumnOpt.value : null; this.props.actions.setTimeColumn(val); diff --git a/caravel/assets/package.json b/caravel/assets/package.json index 07d9e65db963..5965ac1f66d1 100644 --- a/caravel/assets/package.json +++ b/caravel/assets/package.json @@ -110,7 +110,9 @@ "less": "^2.6.1", "less-loader": "^2.2.2", "mocha": "^2.4.5", + "nock": "^8.0.0", "react-addons-test-utils": "^15.3.2", + "redux-mock-store": "^1.2.1", "style-loader": "^0.13.0", "transform-loader": "^0.2.3", "url-loader": "^0.5.7", diff --git a/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx b/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx new file mode 100644 index 000000000000..08f2b96d86aa --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/ChartControl_spec.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Select from 'react-select'; +import { ChartControl } from '../../../../javascripts/explorev2/components/ChartControl'; +import { shallow } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; + +describe('QuerySearch', () => { + it('should render', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + + const wrapper = shallow(); + it('should have two Select', () => { + expect(wrapper.find(Select)).to.have.length(2); + }); +}); + diff --git a/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx b/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx new file mode 100644 index 000000000000..c69bdf473244 --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/Filter_spec.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; +import { Button } from 'react-bootstrap'; +import Select from 'react-select'; +import { Filters } from '../../../../javascripts/explorev2/components/Filters'; +import shortid from 'shortid'; + +function setup() { + const props = { + filters: [ + { + id: shortid.generate(), + field: null, + op: null, + value: null, + }, + ] + } + const wrapper = shallow(); + return { + props, + wrapper, + }; +} + +describe('Filters', () => { + it('renders', () => { + expect(React.isValidElement()).to.equal(true); + }); + + it('should have one button', () => { + const wrapper = shallow(); + expect(wrapper.find(Button)).to.have.length(1); + expect(wrapper.find(Button).contains('Add Filter')).to.eql(true); + }); + + it('should have Select and button for filters', () => { + const { wrapper, props } = setup(); + expect(wrapper.find(Button)).to.have.length(2); + expect(wrapper.find(Select)).to.have.length(2); + expect(wrapper.find('input')).to.have.length(1); + }); +}); diff --git a/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js b/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js new file mode 100644 index 000000000000..fc2b66d665f3 --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/GroupBy_spec.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; +import Select from 'react-select'; +import { GroupBy } from '../../../../javascripts/explorev2/components/GroupBy'; + +describe('GroupBy', () => { + it('renders', () => { + expect(React.isValidElement()).to.equal(true); + }); + + it('should have two Select', () => { + const wrapper = shallow(); + expect(wrapper.find(Select)).to.have.length(2); + }); +}); diff --git a/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js b/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js new file mode 100644 index 000000000000..b2ed94847df5 --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/SqlClause_spec.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; +import { SqlClause } from '../../../../javascripts/explorev2/components/SqlClause'; + +describe('SqlClause', () => { + it('renders', () => { + expect(React.isValidElement()).to.equal(true); + }); + + it('should have two input fields', () => { + const wrapper = shallow(); + expect(wrapper.find('input')).to.have.length(2); + }); +}); diff --git a/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx b/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx new file mode 100644 index 000000000000..e8043811c754 --- /dev/null +++ b/caravel/assets/spec/javascripts/explore/components/TimeFilter_spec.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Select from 'react-select'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; +import { TimeFilter } from '../../../../javascripts/explorev2/components/TimeFilter'; + +describe('TimeFilter', () => { + it('renders', () => { + expect(React.isValidElement()).to.equal(true); + }); + + it('should have four Select', () => { + const wrapper = shallow(); + expect(wrapper.find(Select)).to.have.length(4); + }); +}); diff --git a/caravel/assets/spec/javascripts/explore/components/actions_spec.js b/caravel/assets/spec/javascripts/explore/components/actions_spec.js index 06cb77e4d9ee..a50883e0332c 100644 --- a/caravel/assets/spec/javascripts/explore/components/actions_spec.js +++ b/caravel/assets/spec/javascripts/explore/components/actions_spec.js @@ -1,11 +1,111 @@ import { it, describe } from 'mocha'; -import { expect } from 'chai'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import nock from 'nock'; +import { expect, assert } from 'chai'; +import sinon from 'sinon'; import shortid from 'shortid'; import * as actions from '../../../../javascripts/explorev2/actions/exploreActions'; import { initialState } from '../../../../javascripts/explorev2/stores/store'; import { exploreReducer } from '../../../../javascripts/explorev2/reducers/exploreReducer'; +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +describe('ajax call for datasource metadata', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should return a function', () => { + expect(actions.setFormOpts(999, 'test')).to.be.function; + }); + + it('should dispatch clearAllOpts', () => { + const dispatch = sinon.spy(); + actions.setFormOpts(null, null)(dispatch); + assert(dispatch.withArgs(actions.clearAllOpts()).calledOnce); + }); + + it('should dispatch new opts', () => { + nock('/caravel') + .get('/fetch_datasource_metadata') + .query({ datasource_id: 999, datasource_type: 'test' }) + .reply(200, { + datasource_class: 'SqlaTable', + time_columns: ['col'], + time_grains: [], + groupby_cols: [], + metrics: [], + filter_cols: [], + }); + + const store = mockStore(initialState); + store.dispatch = sinon.spy(); + store.dispatch(actions.setFormOpts(999, 'test')); + expect(store.dispatch.callCount).to.equal(5); + expect(store.getState().timeColumnOpts).to.eql(['col']); + }); +}); + describe('reducers', () => { + it('should return new state with time column options', () => { + const newState = exploreReducer(initialState, actions.setTimeColumnOpts(['col1', 'col2'])); + expect(newState.timeColumnOpts).to.eql(['col1', 'col2']); + }); + it('should return new state with time grain options', () => { + const newState = exploreReducer(initialState, actions.setTimeGrainOpts(['day', 'week'])); + expect(newState.timeGrainOpts).to.eql(['day', 'week']); + }); + + it('should return new state with groupby column options', () => { + const newState = exploreReducer(initialState, actions.setGroupByColumnOpts(['col1', 'col2'])); + expect(newState.groupByColumnOpts).to.eql(['col1', 'col2']); + }); + + it('should return new state with metrics options', () => { + const newState = exploreReducer(initialState, actions.setMetricsOpts(['metric1', 'metric2'])); + expect(newState.metricsOpts).to.eql(['metric1', 'metric2']); + }); + + it('should return new state with filter column options', () => { + const newState = exploreReducer(initialState, actions.setFilterColumnOpts(['col1', 'col2'])); + expect(newState.filterColumnOpts).to.eql(['col1', 'col2']); + }); + + it('should return new state with all form data reset', () => { + const newState = exploreReducer(initialState, actions.resetFormData()); + expect(newState.vizType).to.not.exist; + expect(newState.timeColumn).to.not.exist; + expect(newState.timeGrain).to.not.exist; + expect(newState.since).to.not.exist; + expect(newState.until).to.not.exist; + expect(newState.groupByColumns).to.be.empty; + expect(newState.metrics).to.be.empty; + expect(newState.columns).to.be.empty; + expect(newState.orderings).to.be.empty; + expect(newState.timeStampFormat).to.not.exist; + expect(newState.rowLimit).to.not.exist; + expect(newState.searchBox).to.be.false; + expect(newState.whereClause).to.be.empty; + expect(newState.havingClause).to.be.empty; + expect(newState.filters).to.be.empty; + }); + + it('should clear all options in store', () => { + const newState = exploreReducer(initialState, actions.clearAllOpts()); + expect(newState.timeColumnOpts).to.be.empty; + expect(newState.timeGrainOpts).to.be.empty; + expect(newState.groupByColumnOpts).to.be.empty; + expect(newState.metricsOpts).to.be.empty; + expect(newState.filterColumnOpts).to.be.empty; + }); + + // it('should return new state with datasource class', () => { + // const newState = exploreReducer(initialState, actions.setDatasourceClass('SqlaTable')); + // expect(newState.datasourceClass).to.equal('SqlaTable'); + // }); + it('should return new state with datasource id', () => { const newState = exploreReducer(initialState, actions.setDatasource(1)); expect(newState.datasourceId).to.equal(1); @@ -16,6 +116,36 @@ describe('reducers', () => { expect(newState.vizType).to.equal('bar'); }); + it('should return new state with time column', () => { + const newState = exploreReducer(initialState, actions.setTimeColumn('ds')); + expect(newState.timeColumn).to.equal('ds'); + }); + + it('should return new state with time grain', () => { + const newState = exploreReducer(initialState, actions.setTimeGrain('day')); + expect(newState.timeGrain).to.equal('day'); + }); + + it('should return new state with since', () => { + const newState = exploreReducer(initialState, actions.setSince('1 day ago')); + expect(newState.since).to.equal('1 day ago'); + }); + + it('should return new state with until', () => { + const newState = exploreReducer(initialState, actions.setUntil('now')); + expect(newState.until).to.equal('now'); + }); + + it('should return new state with groupby columns', () => { + const newState = exploreReducer(initialState, actions.setGroupByColumns(['col1', 'col2'])); + expect(newState.groupByColumns).to.eql(['col1', 'col2']); + }); + + it('should return new state with metrics', () => { + const newState = exploreReducer(initialState, actions.setMetrics(['sum', 'count'])); + expect(newState.metrics).to.eql(['sum', 'count']); + }); + it('should return new state with added column', () => { const newColumn = 'col'; const newState = exploreReducer(initialState, actions.addColumn(newColumn)); @@ -57,6 +187,16 @@ describe('reducers', () => { expect(newState.searchBox).to.equal(true); }); + it('should return new state with where clause', () => { + const newState = exploreReducer(initialState, actions.setWhereClause('where')); + expect(newState.whereClause).to.equal('where'); + }); + + it('should return new state with having clause', () => { + const newState = exploreReducer(initialState, actions.setHavingClause('having')); + expect(newState.havingClause).to.equal('having'); + }); + it('should return new state with added filter', () => { const newFilter = { id: shortid.generate(), diff --git a/caravel/views.py b/caravel/views.py index 42bec0b73fe8..b130e5e1e046 100755 --- a/caravel/views.py +++ b/caravel/views.py @@ -75,6 +75,8 @@ class ListWidgetWithCheckboxes(ListWidget): ALL_DATASOURCE_ACCESS_ERR = __( "This endpoint requires the `all_datasource_access` permission") DATASOURCE_MISSING_ERR = __("The datasource seems to have been deleted") +DATASOURCE_ACCESS_ERR = __( + "User does not have access to this datasource") ACCESS_REQUEST_MISSING_ERR = __( "The access requests seem to have been deleted") USER_MISSING_ERR = __("The user seems to have been deleted") @@ -1207,6 +1209,7 @@ def explore(self, datasource_type, datasource_id, slice_id=None): "datasources": [(d.id, d.full_name) for d in datasources], "datasource_id": datasource_id, "datasource_type": datasource_type, + "datasource_class": datasource_class.__name__, "user_id": g.user.get_id() if g.user else None, "viz": json.loads(viz_obj.get_json()) } diff --git a/tests/core_tests.py b/tests/core_tests.py index 0f16b3a77d2a..7a53d1a7d9b6 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -307,6 +307,19 @@ def test_csv_endpoint(self): self.assertEqual(list(expected_data), list(data)) self.logout() + def test_datasource_metadata_endpoint(self): + params = [ + 'datasource_id=1', + 'datasource_type=table' + ] + resp = self.client.get('/caravel/fetch_datasource_metadata?'+'&'.join(params)) + self.assertEquals(500, resp.status_code) + + self.login('admin') + resp = self.client.get('/caravel/fetch_datasource_metadata?'+'&'.join(params)) + self.assertEquals(200, resp.status_code) + self.logout() + def test_queries_endpoint(self): resp = self.client.get('/caravel/queries/{}'.format(0)) self.assertEquals(403, resp.status_code) From 6f8827deefacff4a4b0a8e2d06c4561d04f1b748 Mon Sep 17 00:00:00 2001 From: Vera Liu Date: Tue, 4 Oct 2016 12:05:38 -0700 Subject: [PATCH 3/3] Resolve conflicts --- caravel/templates/caravel/explore.html | 302 ++++++++++++++++++++++++- caravel/views.py | 21 +- 2 files changed, 303 insertions(+), 20 deletions(-) diff --git a/caravel/templates/caravel/explore.html b/caravel/templates/caravel/explore.html index 57e0c3a4c7fa..1ea84c5de88d 100644 --- a/caravel/templates/caravel/explore.html +++ b/caravel/templates/caravel/explore.html @@ -1,15 +1,307 @@ {% extends "caravel/basic.html" %} +{% block title %} + {% if slice %} + [slice] {{ slice.slice_name }} + {% else %} + [explore] {{ viz.datasource.table_name }} + {% endif %} +{% endblock %} + {% block body %} -
+ {% set datasource = viz.datasource %} + {% set form = viz.form %} + + {% macro panofield(fieldname)%} +
+ {% set field = form.get_field(fieldname)%} +
+ {{ field.label }} + {% if field.description %} + + {% endif %} + {{ field(class_=form.field_css_classes(field.name)) }} +
+
+ {% endmacro %} + +
+ {% endblock %} {% block tail_js %} {{ super() }} - {% with filename="explorev2" %} + {% with filename="explore" %} {% include "caravel/partials/_script_tag.html" %} {% endwith %} {% endblock %} diff --git a/caravel/views.py b/caravel/views.py index b130e5e1e046..4ab411c1546e 100755 --- a/caravel/views.py +++ b/caravel/views.py @@ -75,11 +75,10 @@ class ListWidgetWithCheckboxes(ListWidget): ALL_DATASOURCE_ACCESS_ERR = __( "This endpoint requires the `all_datasource_access` permission") DATASOURCE_MISSING_ERR = __("The datasource seems to have been deleted") -DATASOURCE_ACCESS_ERR = __( - "User does not have access to this datasource") ACCESS_REQUEST_MISSING_ERR = __( "The access requests seem to have been deleted") USER_MISSING_ERR = __("The user seems to have been deleted") +DATASOURCE_ACCESS_ERR = __("User does not have access to this datasource") def get_database_access_error_msg(database_name): @@ -1201,19 +1200,11 @@ def explore(self, datasource_type, datasource_id, slice_id=None): template = "caravel/standalone.html" else: template = "caravel/explore.html" - bootstrap_data = { - "can_add": slice_add_perm, - "can_download": slice_download_perm, - "can_edit": slice_edit_perm, - # TODO: separate endpoint for fetching datasources - "datasources": [(d.id, d.full_name) for d in datasources], - "datasource_id": datasource_id, - "datasource_type": datasource_type, - "datasource_class": datasource_class.__name__, - "user_id": g.user.get_id() if g.user else None, - "viz": json.loads(viz_obj.get_json()) - } - return self.render_template(template, bootstrap_data=json.dumps(bootstrap_data)) + return self.render_template( + template, viz=viz_obj, slice=slc, datasources=datasources, + can_add=slice_add_perm, can_edit=slice_edit_perm, + can_download=slice_download_perm, + userid=g.user.get_id() if g.user else '') @has_access @expose("/exploreV2////")