This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push: new 68ba63f Implement a React-based table editor (#5186) 68ba63f is described below commit 68ba63fcd9e426f2ff43a14fc259afef4a272e38 Author: Maxime Beauchemin <maximebeauche...@gmail.com> AuthorDate: Mon Aug 6 15:30:13 2018 -0700 Implement a React-based table editor (#5186) * A React table editor * addressing comments * Fix SelectAsyncControl error on clear * fix tests * more corrections * Removed <strong> --- .../dashboard => }/fixtures/mockDatasource.js | 0 .../spec/javascripts/CRUD/CollectionTable_spec.jsx | 34 ++ .../dashboard/components/Dashboard_spec.jsx | 2 +- .../components/gridComponents/Chart_spec.jsx | 2 +- .../dashboard/fixtures/mockChartQueries.js | 2 +- .../dashboard/fixtures/mockSliceEntities.js | 2 +- .../javascripts/dashboard/fixtures/mockState.js | 2 +- .../datasource/DatasourceEditor_spec.jsx | 73 +++ .../datasource/DatasourceModal_spec.jsx | 59 +++ .../explore/components/DatasourceControl_spec.jsx | 4 +- .../javascripts/explore/exploreActions_spec.js | 67 +-- .../javascripts/profile/CreatedContent_spec.jsx | 2 +- .../spec/javascripts/profile/Favorites_spec.jsx | 2 +- .../javascripts/profile/RecentActivity_spec.jsx | 2 +- .../javascripts/sqllab/SqlEditorLeftBar_spec.jsx | 4 - superset/assets/src/CRUD/CollectionTable.jsx | 220 ++++++++ superset/assets/src/CRUD/Field.jsx | 62 +++ superset/assets/src/CRUD/Fieldset.jsx | 48 ++ superset/assets/src/CRUD/styles.css | 13 + superset/assets/src/CRUD/styles.less | 14 + superset/assets/src/CRUD/utils.js | 20 + superset/assets/src/components/Checkbox.jsx | 4 +- superset/assets/src/components/EditableTitle.jsx | 37 +- .../src/{profile => }/components/TableLoader.jsx | 4 +- .../assets/src/datasource/DatasourceEditor.jsx | 575 +++++++++++++++++++++ superset/assets/src/datasource/DatasourceModal.jsx | 161 ++++++ superset/assets/src/datasource/main.css | 4 + .../assets/src/explore/actions/exploreActions.js | 41 +- .../explore/components/ControlPanelsContainer.jsx | 2 +- .../components/controls/CheckboxControl.jsx | 32 +- .../components/controls/DatasourceControl.jsx | 87 +--- .../components/controls/SelectAsyncControl.jsx | 11 +- .../components/controls/TextAreaControl.jsx | 7 +- superset/assets/src/explore/controls.jsx | 3 +- .../assets/src/explore/reducers/exploreReducer.js | 7 +- .../src/profile/components/CreatedContent.jsx | 2 +- .../assets/src/profile/components/Favorites.jsx | 2 +- .../src/profile/components/RecentActivity.jsx | 2 +- .../assets/stylesheets/less/cosmo/variables.less | 2 +- superset/assets/stylesheets/superset.less | 3 + superset/connectors/base/models.py | 118 ++++- superset/connectors/druid/models.py | 25 +- superset/connectors/sqla/models.py | 20 +- superset/data/__init__.py | 2 +- superset/models/core.py | 4 + superset/views/__init__.py | 1 + superset/views/base.py | 56 +- superset/views/core.py | 56 +- superset/views/datasource.py | 49 ++ tests/base_tests.py | 58 +-- tests/datasource_tests.py | 52 ++ tests/druid_tests.py | 15 + tests/fixtures/__init__.py | 0 tests/fixtures/datasource.py | 148 ++++++ tests/security_tests.py | 49 +- 55 files changed, 1918 insertions(+), 355 deletions(-) diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/fixtures/mockDatasource.js similarity index 100% rename from superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js rename to superset/assets/spec/fixtures/mockDatasource.js diff --git a/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx new file mode 100644 index 0000000..6955688 --- /dev/null +++ b/superset/assets/spec/javascripts/CRUD/CollectionTable_spec.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; + +import CollectionTable from '../../../src/CRUD/CollectionTable'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + collection: mockDatasource['7__table'].columns, + tableColumns: ['column_name', 'type', 'groupby'], +}; + +describe('CollectionTable', () => { + + let wrapper; + let el; + + beforeEach(() => { + el = <CollectionTable {...props} />; + wrapper = shallow(el); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders a table', () => { + const length = mockDatasource['7__table'].columns.length; + expect(wrapper.find('table')).to.have.lengthOf(1); + expect(wrapper.find('tbody tr.row')).to.have.lengthOf(length); + }); + +}); diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx index e855009..aa82f01 100644 --- a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx @@ -9,7 +9,7 @@ import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuil // mock data import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries'; -import datasources from '../fixtures/mockDatasource'; +import datasources from '../../../fixtures/mockDatasource'; import dashboardInfo from '../fixtures/mockDashboardInfo'; import { dashboardLayout } from '../fixtures/mockDashboardLayout'; import dashboardState from '../fixtures/mockDashboardState'; diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx index dcd7119..dbd7054 100644 --- a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx @@ -8,7 +8,7 @@ import Chart from '../../../../../src/dashboard/components/gridComponents/Chart' import SliceHeader from '../../../../../src/dashboard/components/SliceHeader'; import ChartContainer from '../../../../../src/chart/ChartContainer'; -import mockDatasource from '../../fixtures/mockDatasource'; +import mockDatasource from '../../../../fixtures/mockDatasource'; import { sliceEntitiesForChart as sliceEntities, sliceId, diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js index b5004a1..81ecb20 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js @@ -1,4 +1,4 @@ -import { datasourceId } from './mockDatasource'; +import { datasourceId } from '../../../fixtures/mockDatasource'; export const sliceId = 18; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js index 03d8e30..c7a9c7a 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js @@ -1,5 +1,5 @@ import { sliceId as id } from './mockChartQueries'; -import { datasourceId } from './mockDatasource'; +import { datasourceId } from '../../../fixtures/mockDatasource'; export const sliceId = id; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js index 514442f..32d8120 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js +++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js @@ -3,7 +3,7 @@ import { dashboardLayout } from './mockDashboardLayout'; import dashboardInfo from './mockDashboardInfo'; import dashboardState from './mockDashboardState'; import messageToasts from '../../messageToasts/mockMessageToasts'; -import datasources from './mockDatasource'; +import datasources from '../../../fixtures/mockDatasource'; import sliceEntities from './mockSliceEntities'; export default { diff --git a/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx new file mode 100644 index 0000000..137ac39 --- /dev/null +++ b/superset/assets/spec/javascripts/datasource/DatasourceEditor_spec.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Tabs } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import configureStore from 'redux-mock-store'; +import $ from 'jquery'; +import sinon from 'sinon'; + +import DatasourceEditor from '../../../src/datasource/DatasourceEditor'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + datasource: mockDatasource['7__table'], + addSuccessToast: () => {}, + addDangerToast: () => {}, + onChange: sinon.spy(), +}; +const extraColumn = { + column_name: 'new_column', + type: 'VARCHAR(10)', + description: null, + filterable: true, + verbose_name: null, + is_dttm: false, + expression: '', + groupby: true, +}; + +describe('DatasourceEditor', () => { + const mockStore = configureStore([]); + const store = mockStore({}); + + let wrapper; + let el; + let ajaxStub; + let inst; + + beforeEach(() => { + ajaxStub = sinon.stub($, 'ajax'); + el = <DatasourceEditor {...props} />; + wrapper = shallow(el, { context: { store } }).dive(); + inst = wrapper.instance(); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders Tabs', () => { + expect(wrapper.find(Tabs)).to.have.lengthOf(1); + }); + + it('makes an async request', () => { + wrapper.setState({ activeTabKey: 2 }); + const syncButton = wrapper.find('.sync-from-source'); + expect(syncButton).to.have.lengthOf(1); + syncButton.simulate('click'); + expect(ajaxStub.calledOnce).to.equal(true); + }); + + it('merges columns', () => { + const numCols = props.datasource.columns.length; + expect(inst.state.databaseColumns.length).to.equal(numCols); + inst.mergeColumns([extraColumn]); + expect(inst.state.databaseColumns.length).to.equal(numCols + 1); + }); + +}); diff --git a/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx new file mode 100644 index 0000000..3cbc92f --- /dev/null +++ b/superset/assets/spec/javascripts/datasource/DatasourceModal_spec.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import configureStore from 'redux-mock-store'; +import { shallow } from 'enzyme'; +import $ from 'jquery'; +import sinon from 'sinon'; + +import DatasourceModal from '../../../src/datasource/DatasourceModal'; +import DatasourceEditor from '../../../src/datasource/DatasourceEditor'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + datasource: mockDatasource['7__table'], + addSuccessToast: () => {}, + addDangerToast: () => {}, + onChange: sinon.spy(), + show: true, + onHide: () => {}, +}; + +describe('DatasourceModal', () => { + const mockStore = configureStore([]); + const store = mockStore({}); + + let wrapper; + let el; + let ajaxStub; + let inst; + + beforeEach(() => { + ajaxStub = sinon.stub($, 'ajax'); + el = <DatasourceModal {...props} />; + wrapper = shallow(el, { context: { store } }).dive(); + inst = wrapper.instance(); + }); + + afterEach(() => { + ajaxStub.restore(); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).to.equal(true); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).to.have.lengthOf(1); + }); + + it('renders a DatasourceEditor', () => { + expect(wrapper.find(DatasourceEditor)).to.have.lengthOf(1); + }); + + it('saves on confirm', () => { + inst.onConfirmSave(); + expect(ajaxStub.calledOnce).to.equal(true); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx index 68dc783..d03e30b 100644 --- a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx +++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx @@ -4,7 +4,7 @@ import configureStore from 'redux-mock-store'; import { expect } from 'chai'; import { describe, it } from 'mocha'; import { shallow } from 'enzyme'; -import { Modal } from 'react-bootstrap'; +import DatasourceModal from '../../../../src/datasource/DatasourceModal'; import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl'; const defaultProps = { @@ -35,6 +35,6 @@ describe('DatasourceControl', () => { it('renders a Modal', () => { const wrapper = setup(); - expect(wrapper.find(Modal)).to.have.lengthOf(1); + expect(wrapper.find(DatasourceModal)).to.have.lengthOf(1); }); }); diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js index a87a45d..72aebbd 100644 --- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js +++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js @@ -1,12 +1,9 @@ /* eslint-disable no-unused-expressions */ import { it, describe } from 'mocha'; import { expect } from 'chai'; -import sinon from 'sinon'; -import $ from 'jquery'; -import * as chartActions from '../../../src/chart/chartAction'; -import * as actions from '../../../src/explore/actions/exploreActions'; import { defaultState } from '../../../src/explore/store'; import exploreReducer from '../../../src/explore/reducers/exploreReducer'; +import * as actions from '../../../src/explore/actions/exploreActions'; describe('reducers', () => { it('sets correct control value given a key and value', () => { @@ -20,65 +17,3 @@ describe('reducers', () => { expect(newState.controls.show_legend.value).to.equal(true); }); }); - -describe('fetching actions', () => { - let dispatch; - let request; - let ajaxStub; - - beforeEach(() => { - dispatch = sinon.spy(); - ajaxStub = sinon.stub($, 'ajax'); - }); - afterEach(() => { - ajaxStub.restore(); - }); - - describe('fetchDatasourceMetadata', () => { - const datasourceKey = '1__table'; - - const makeRequest = (alsoTriggerQuery = false) => { - request = actions.fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery); - request(dispatch); - }; - - it('calls fetchDatasourceStarted', () => { - makeRequest(); - expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCE_STARTED); - }); - - it('makes the ajax request', () => { - makeRequest(); - expect(ajaxStub.calledOnce).to.be.true; - }); - - it('calls correct url', () => { - const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`; - makeRequest(); - expect(ajaxStub.getCall(0).args[0].url).to.equal(url); - }); - - it('calls correct actions on error', () => { - ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } }); - makeRequest(); - expect(dispatch.callCount).to.equal(2); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCE_FAILED); - }); - - it('calls correct actions on success', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(); - expect(dispatch.callCount).to.equal(4); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCE); - expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCE_SUCCEEDED); - expect(dispatch.getCall(3).args[0].type).to.equal(actions.RESET_FIELDS); - }); - - it('triggers query if flag is set', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(true); - expect(dispatch.callCount).to.equal(5); - expect(dispatch.getCall(4).args[0].type).to.equal(chartActions.TRIGGER_QUERY); - }); - }); -}); diff --git a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx index ac33d99..08457d9 100644 --- a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx +++ b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx @@ -4,7 +4,7 @@ import { describe, it } from 'mocha'; import { expect } from 'chai'; import { user } from './fixtures'; import CreatedContent from '../../../src/profile/components/CreatedContent'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('CreatedContent', () => { diff --git a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx index c9315dc..252d99e 100644 --- a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx +++ b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { user } from './fixtures'; import Favorites from '../../../src/profile/components/Favorites'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('Favorites', () => { const mockedProps = { diff --git a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx index f3c200f..60240fc 100644 --- a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx +++ b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { user } from './fixtures'; import RecentActivity from '../../../src/profile/components/RecentActivity'; -import TableLoader from '../../../src/profile/components/TableLoader'; +import TableLoader from '../../../src/components/TableLoader'; describe('RecentActivity', () => { diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx index 16f1f8b..bc4957e 100644 --- a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx +++ b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx @@ -9,10 +9,6 @@ import { table, defaultQueryEditor, databases, tables } from './fixtures'; import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar'; import TableElement from '../../../src/SqlLab/components/TableElement'; -global.notify = { - error: () => {}, -}; - describe('SqlEditorLeftBar', () => { const mockedProps = { actions: { diff --git a/superset/assets/src/CRUD/CollectionTable.jsx b/superset/assets/src/CRUD/CollectionTable.jsx new file mode 100644 index 0000000..4520225 --- /dev/null +++ b/superset/assets/src/CRUD/CollectionTable.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import shortid from 'shortid'; + +import Button from '../components/Button'; +import Fieldset from './Fieldset'; +import { recurseReactClone } from './utils'; +import './styles.css'; + +const propTypes = { + collection: PropTypes.array, + itemGenerator: PropTypes.func, + columnLabels: PropTypes.object, + tableColumns: PropTypes.array, + columns: PropTypes.array, + onChange: PropTypes.func, + itemRenderers: PropTypes.object, + allowDeletes: PropTypes.bool, + expandFieldset: PropTypes.node, + emptyMessage: PropTypes.node, + extraButtons: PropTypes.node, + allowAddItem: PropTypes.bool, +}; +const defaultProps = { + onChange: () => {}, + itemRenderers: {}, + columnLabels: {}, + allowDeletes: false, + emptyMessage: 'No entries', + allowAddItem: false, + itemGenerator: () => ({}), +}; +const Frame = props => ( + <div className="frame"> + {props.children} + </div>); +Frame.propTypes = { children: PropTypes.node }; + +function createKeyedCollection(arr) { + const newArr = arr.map(o => ({ + ...o, + id: o.id || shortid.generate(), + })); + const map = {}; + newArr.forEach((o) => { + map[o.id] = o; + }); + return map; +} + +export default class CRUDCollection extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + expandedColumns: {}, + collection: createKeyedCollection(props.collection), + }; + this.renderItem = this.renderItem.bind(this); + this.onAddItem = this.onAddItem.bind(this); + this.renderExpandableSection = this.renderExpandableSection.bind(this); + this.getLabel = this.getLabel.bind(this); + this.onFieldsetChange = this.onFieldsetChange.bind(this); + this.renderTableBody = this.renderTableBody.bind(this); + this.changeCollection = this.changeCollection.bind(this); + } + componentWillReceiveProps(nextProps) { + if (nextProps.collection !== this.props.collection) { + this.setState({ + collection: createKeyedCollection(nextProps.collection), + }); + } + } + onCellChange(id, col, val) { + this.changeCollection({ + ...this.state.collection, + [id]: { + ...this.state.collection[id], + [col]: val, + }, + }); + + } + onAddItem() { + let newItem = this.props.itemGenerator(); + if (!newItem.id) { + newItem = { ...newItem, id: shortid.generate() }; + } + this.changeCollection({ + ...this.state.collection, + [newItem.id]: newItem, + }); + } + onFieldsetChange(item) { + this.changeCollection({ + ...this.state.collection, + [item.id]: item, + }); + } + getLabel(col) { + const { columnLabels } = this.props; + let label = columnLabels[col] ? columnLabels[col] : col; + if (label.startsWith('__')) { + label = ''; + } + return label; + } + changeCollection(collection) { + this.setState({ collection }); + this.props.onChange(Object.keys(collection).map(k => collection[k])); + } + deleteItem(id) { + const newColl = { ...this.state.collection }; + delete newColl[id]; + this.changeCollection(newColl); + } + effectiveTableColumns() { + const { tableColumns, allowDeletes, expandFieldset } = this.props; + const cols = allowDeletes ? tableColumns.concat(['__actions']) : tableColumns; + return expandFieldset ? ['__expand'].concat(cols) : cols; + } + toggleExpand(id) { + this.onCellChange(id, '__expanded', false); + this.setState({ + expandedColumns: { + ...this.state.expandedColumns, + [id]: !this.state.expandedColumns[id], + }, + }); + } + renderHeaderRow() { + const cols = this.effectiveTableColumns(); + return ( + <thead> + <tr> + {this.props.expandFieldset && <th className="tiny-cell" />} + {cols.map(col => <th key={col}>{this.getLabel(col)}</th>)} + {this.props.allowDeletes && <th className="tiny-cell" />} + </tr> + </thead> + ); + } + renderExpandableSection(item) { + const propsGenerator = () => ({ item, onChange: this.onFieldsetChange }); + return recurseReactClone(this.props.expandFieldset, Fieldset, propsGenerator); + } + renderCell(record, col) { + const renderer = this.props.itemRenderers[col]; + const val = record[col]; + const onChange = this.onCellChange.bind(this, record.id, col); + return renderer ? renderer(val, onChange, this.getLabel(col)) : val; + } + renderItem(record) { + const { tableColumns, allowDeletes, expandFieldset } = this.props; + /* eslint-disable no-underscore-dangle */ + const isExpanded = !!this.state.expandedColumns[record.id] || record.__expanded; + let tds = []; + if (expandFieldset) { + tds.push( + <td key="__expand" className="expand"> + <i + className={`fa fa-caret-${isExpanded ? 'down' : 'right'} text-primary pointer`} + onClick={this.toggleExpand.bind(this, record.id)} + /> + </td>); + } + tds = tds.concat(tableColumns.map(col => ( + <td key={col}>{this.renderCell(record, col)}</td> + ))); + if (allowDeletes) { + tds.push( + <td key="__actions"> + <i + className="fa fa-close text-primary pointer" + onClick={this.deleteItem.bind(this, record.id)} + /> + </td>); + } + const trs = [<tr className="row" key={record.id}>{tds}</tr>]; + if (isExpanded) { + trs.push( + <tr className="exp" key={'exp__' + record.id}> + <td colSpan={this.effectiveTableColumns().length} className="expanded"> + <div> + {this.renderExpandableSection(record)} + </div> + </td> + </tr>); + } + return trs; + } + renderEmptyCell() { + return <tr><td className="empty-collection">{this.props.emptyMessage}</td></tr>; + } + renderTableBody() { + const data = Object.keys(this.state.collection).map(k => this.state.collection[k]); + const content = data.length ? data.map(d => this.renderItem(d)) : this.renderEmptyCell(); + return <tbody>{content}</tbody>; + } + render() { + return ( + <div className="CRUD"> + <table + className="table" + > + {this.renderHeaderRow()} + {this.renderTableBody()} + </table> + <div> + {this.props.allowAddItem && + <Button bsStyle="primary" onClick={this.onAddItem}> + <i className="fa fa-plus" /> Add Item + </Button>} + {this.props.extraButtons} + </div> + </div> + ); + } +} +CRUDCollection.defaultProps = defaultProps; +CRUDCollection.propTypes = propTypes; diff --git a/superset/assets/src/CRUD/Field.jsx b/superset/assets/src/CRUD/Field.jsx new file mode 100644 index 0000000..0fa0ad7 --- /dev/null +++ b/superset/assets/src/CRUD/Field.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + FormGroup, ControlLabel, HelpBlock, FormControl, OverlayTrigger, Tooltip, +} from 'react-bootstrap'; + +import './styles.less'; + +const propTypes = { + value: PropTypes.any, + label: PropTypes.string, + descr: PropTypes.node, + fieldKey: PropTypes.string.isRequired, + control: PropTypes.node.isRequired, + onChange: PropTypes.func, + compact: PropTypes.bool, +}; +const defaultProps = { + controlProps: {}, + onChange: () => {}, + compact: false, +}; + +export default class Field extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + onChange(newValue) { + this.props.onChange(this.props.fieldKey, newValue); + } + render() { + const { compact, value, label, control, descr, fieldKey } = this.props; + const hookedControl = React.cloneElement(control, { value, onChange: this.onChange }); + return ( + <FormGroup + controlId={fieldKey} + > + <ControlLabel className="m-r-5"> + {label || fieldKey} + {compact && descr && + <OverlayTrigger + placement="right" + overlay={ + <Tooltip id="field-descr" bsSize="lg">{descr}</Tooltip> + } + > + <i className="fa fa-info-circle m-l-5" /> + </OverlayTrigger> + } + </ControlLabel> + {hookedControl} + <FormControl.Feedback /> + {!compact && descr && + <HelpBlock>{descr}</HelpBlock> + } + </FormGroup> + ); + } +} +Field.propTypes = propTypes; +Field.defaultProps = defaultProps; diff --git a/superset/assets/src/CRUD/Fieldset.jsx b/superset/assets/src/CRUD/Fieldset.jsx new file mode 100644 index 0000000..6c3c74e --- /dev/null +++ b/superset/assets/src/CRUD/Fieldset.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from 'react-bootstrap'; + +import { recurseReactClone } from './utils'; +import Field from './Field'; + +const propTypes = { + children: PropTypes.node, + onChange: PropTypes.func, + item: PropTypes.object, + title: PropTypes.node, + compact: PropTypes.bool, +}; +const defaultProps = { + compact: false, +}; + +export default class Fieldset extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + onChange(fieldKey, val) { + return this.props.onChange({ + ...this.props.item, + [fieldKey]: val, + }); + } + render() { + const { title } = this.props; + const propExtender = field => ({ + onChange: this.onChange, + value: this.props.item[field.props.fieldKey], + compact: this.props.compact, + }); + return ( + <Form componentClass="fieldset" className="CRUD"> + {title && + <legend>{title}</legend> + } + {recurseReactClone(this.props.children, Field, propExtender)} + </Form> + ); + } +} +Fieldset.propTypes = propTypes; +Fieldset.defaultProps = defaultProps; diff --git a/superset/assets/src/CRUD/styles.css b/superset/assets/src/CRUD/styles.css new file mode 100644 index 0000000..1f07c59 --- /dev/null +++ b/superset/assets/src/CRUD/styles.css @@ -0,0 +1,13 @@ +.CRUD i.fa-caret-down, i.fa-caret-up { + width: '5px'; +} +.CRUD td.expanded = { + border-top: '0px'; + padding: '0px'; +} +.CRUD .frame = { + border: '1px solid #AAA'; + border-radius: 5; + padding: 10; + background: '#F4F4F4'; +} diff --git a/superset/assets/src/CRUD/styles.less b/superset/assets/src/CRUD/styles.less new file mode 100644 index 0000000..ec632a6 --- /dev/null +++ b/superset/assets/src/CRUD/styles.less @@ -0,0 +1,14 @@ +.CRUD .text-right { + text-align: right; +} +.CRUD .empty-collection { + padding: 10px; +} +.CRUD .control-label { + font-weight: bold; + font-size: 16px; +} + +.CRUD .tiny-cell { + width: 5px; +} diff --git a/superset/assets/src/CRUD/utils.js b/superset/assets/src/CRUD/utils.js new file mode 100644 index 0000000..6de8c4b --- /dev/null +++ b/superset/assets/src/CRUD/utils.js @@ -0,0 +1,20 @@ +import React from 'react'; + +export function recurseReactClone(children, type, propExtender) { + /** + * Clones a React component's children, and injects new props + * where the type specified is matched. + */ + return React.Children.map(children, (child) => { + let newChild = child; + if (child && child.type === type) { + newChild = React.cloneElement(child, propExtender(child)); + } + if (newChild && newChild.props.children) { + newChild = React.cloneElement(newChild, { + children: recurseReactClone(newChild.props.children, type, propExtender), + }); + } + return newChild; + }); +} diff --git a/superset/assets/src/components/Checkbox.jsx b/superset/assets/src/components/Checkbox.jsx index b0564ae..c3b2506 100644 --- a/superset/assets/src/components/Checkbox.jsx +++ b/superset/assets/src/components/Checkbox.jsx @@ -12,7 +12,9 @@ export default function Checkbox({ checked, onChange, style }) { <span style={style}> <i className={`fa fa-check ${checked ? 'text-primary' : 'text-transparent'}`} - onClick={onChange.bind(!checked)} + onClick={() => { + onChange(!checked); + }} style={{ border: '1px solid #aaa', borderRadius: '2px', diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx index 45fea1d..5e206c9 100644 --- a/superset/assets/src/components/EditableTitle.jsx +++ b/superset/assets/src/components/EditableTitle.jsx @@ -10,14 +10,18 @@ const propTypes = { onSaveTitle: PropTypes.func, noPermitTooltip: PropTypes.string, showTooltip: PropTypes.bool, + emptyText: PropTypes.node, + style: PropTypes.object, }; const defaultProps = { title: t('Title'), canEdit: false, showTooltip: true, + onSaveTitle: () => {}, + emptyText: '<empty>', }; -class EditableTitle extends React.PureComponent { +export default class EditableTitle extends React.PureComponent { constructor(props) { super(props); this.state = { @@ -45,10 +49,7 @@ class EditableTitle extends React.PureComponent { if (!this.props.canEdit || this.state.isEditing) { return; } - - this.setState({ - isEditing: true, - }); + this.setState({ isEditing: true }); } handleBlur() { @@ -101,7 +102,6 @@ class EditableTitle extends React.PureComponent { if (!this.props.canEdit) { return; } - this.setState({ title: ev.target.value, }); @@ -110,17 +110,23 @@ class EditableTitle extends React.PureComponent { handleKeyPress(ev) { if (ev.key === 'Enter') { ev.preventDefault(); - this.handleBlur(); } } render() { - let content = ( + let value; + if (this.state.title) { + value = this.state.title; + } else if (!this.state.isEditing) { + value = this.props.emptyText; + } + let input = ( <input required type={this.state.isEditing ? 'text' : 'button'} - value={this.state.title} + value={value} + className={!this.state.title ? 'text-muted' : null} onKeyUp={this.handleKeyUp} onChange={this.handleChange} onBlur={this.handleBlur} @@ -128,14 +134,14 @@ class EditableTitle extends React.PureComponent { onKeyPress={this.handleKeyPress} /> ); - if (this.props.showTooltip) { - content = ( + if (this.props.showTooltip && !this.state.isEditing) { + input = ( <TooltipWrapper label="title" - tooltip={this.props.canEdit ? t('click to edit title') : + tooltip={this.props.canEdit ? t('click to edit') : this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')} > - {content} + {input} </TooltipWrapper> ); } @@ -146,13 +152,12 @@ class EditableTitle extends React.PureComponent { this.props.canEdit && 'editable-title--editable', this.state.isEditing && 'editable-title--editing', )} + style={this.props.style} > - {content} + {input} </span> ); } } EditableTitle.propTypes = propTypes; EditableTitle.defaultProps = defaultProps; - -export default EditableTitle; diff --git a/superset/assets/src/profile/components/TableLoader.jsx b/superset/assets/src/components/TableLoader.jsx similarity index 94% rename from superset/assets/src/profile/components/TableLoader.jsx rename to superset/assets/src/components/TableLoader.jsx index 462e009..3f51ee9 100644 --- a/superset/assets/src/profile/components/TableLoader.jsx +++ b/superset/assets/src/components/TableLoader.jsx @@ -2,8 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Table, Tr, Td } from 'reactable'; import $ from 'jquery'; -import Loading from '../../components/Loading'; -import '../../../stylesheets/reactable-pagination.css'; +import Loading from '../components/Loading'; +import '../../stylesheets/reactable-pagination.css'; const propTypes = { dataEndpoint: PropTypes.string.isRequired, diff --git a/superset/assets/src/datasource/DatasourceEditor.jsx b/superset/assets/src/datasource/DatasourceEditor.jsx new file mode 100644 index 0000000..12dfdc0 --- /dev/null +++ b/superset/assets/src/datasource/DatasourceEditor.jsx @@ -0,0 +1,575 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Badge, Col, Label, Tabs, Tab, Well } from 'react-bootstrap'; +import shortid from 'shortid'; +import $ from 'jquery'; + +import { t } from '../locales'; + +import Button from '../components/Button'; +import Loading from '../components/Loading'; +import CheckboxControl from '../explore/components/controls/CheckboxControl'; +import TextControl from '../explore/components/controls/TextControl'; +import SelectControl from '../explore/components/controls/SelectControl'; +import TextAreaControl from '../explore/components/controls/TextAreaControl'; +import SelectAsyncControl from '../explore/components/controls/SelectAsyncControl'; +import SpatialControl from '../explore/components/controls/SpatialControl'; +import CollectionTable from '../CRUD/CollectionTable'; +import EditableTitle from '../components/EditableTitle'; +import Fieldset from '../CRUD/Fieldset'; +import Field from '../CRUD/Field'; + +import withToasts from '../messageToasts/enhancers/withToasts'; + +import './main.css'; + +const checkboxGenerator = (d, onChange) => <CheckboxControl value={d} onChange={onChange} />; +const styleMonospace = { fontFamily: 'monospace' }; +const DATA_TYPES = ['STRING', 'NUMBER', 'DATETIME']; + +function CollectionTabTitle({ title, collection }) { + return ( + <div> + {title} <Badge>{collection ? collection.length : 0}</Badge> + </div> + ); +} +CollectionTabTitle.propTypes = { + title: PropTypes.string, + collection: PropTypes.array, +}; + +function ColumnCollectionTable({ + columns, onChange, editableColumnName, showExpression, allowAddItem, + allowEditDataType, itemGenerator, +}) { + return ( + <CollectionTable + collection={columns} + tableColumns={['column_name', 'type', 'is_dttm', 'filterable', 'groupby']} + allowDeletes + allowAddItem={allowAddItem} + itemGenerator={itemGenerator} + expandFieldset={ + <FormContainer> + <Fieldset compact> + {showExpression && + <Field + fieldKey="expression" + label="SQL Expression" + control={<TextControl />} + />} + <Field + fieldKey="verbose_name" + label={t('Label')} + control={<TextControl />} + /> + {allowEditDataType && + <Field + fieldKey="type" + label={t('Data Type')} + control={<SelectControl choices={DATA_TYPES} name="type" />} + />} + <Field + fieldKey="python_date_format" + label="Datetime Format" + descr={ + <div> + {t('The pattern of the timestamp format, use ')} + <a href="https://docs.python.org/2/library/datetime.html#strftime-strptime-behavior"> + {t('python datetime string pattern')} + </a> + {t(` expression. If time is stored in epoch format, put \`epoch_s\` or + \`epoch_ms\`. Leave \`Database Expression\` + below empty if timestamp is stored in ' + String or Integer(epoch) type`)} + </div> + } + control={<TextControl />} + /> + <Field + fieldKey="database_expression" + label="Database Expression" + descr={ + <div> + {t(` + The database expression to cast internal datetime + constants to database date/timestamp type according to the DBAPI. + The expression should follow the pattern of + %Y-%m-%d %H:%M:%S, based on different DBAPI. + The string should be a python string formatter + \`Ex: TO_DATE('{}', 'YYYY-MM-DD HH24:MI:SS')\` for Oracle + Superset uses default expression based on DB URI if this + field is blank. + `)} + </div> + } + control={<TextControl />} + /> + </Fieldset> + </FormContainer> + } + columnLabels={{ + column_name: 'Column', + type: 'Data Type', + groupby: 'Is Dimension', + is_dttm: 'Is Temporal', + filterable: 'Is Filterable', + }} + onChange={onChange} + itemRenderers={{ + column_name: (v, onItemChange) => ( + editableColumnName ? + <EditableTitle canEdit title={v} onSaveTitle={onItemChange} /> : + v + ), + type: d => <Label style={{ fontSize: '75%' }}>{d}</Label>, + is_dttm: checkboxGenerator, + filterable: checkboxGenerator, + groupby: checkboxGenerator, + }} + />); +} +ColumnCollectionTable.propTypes = { + columns: PropTypes.array.isRequired, + onChange: PropTypes.func.isRequired, + editableColumnName: PropTypes.bool, + showExpression: PropTypes.bool, + allowAddItem: PropTypes.bool, + allowEditDataType: PropTypes.bool, + itemGenerator: PropTypes.func, +}; +ColumnCollectionTable.defaultProps = { + editableColumnName: false, + showExpression: false, + allowAddItem: false, + allowEditDataType: false, + itemGenerator: () => ({ + column_name: '<new column>', + filterable: true, + groupby: true, + }), +}; + +function StackedField({ label, formElement }) { + return ( + <div> + <div><strong>{label}</strong></div> + <div>{formElement}</div> + </div> + ); +} +StackedField.propTypes = { + label: PropTypes.string, + formElement: PropTypes.node, +}; + +function FormContainer({ children }) { + return ( + <Well style={{ marginTop: 20 }}> + {children} + </Well> + ); +} +FormContainer.propTypes = { + children: PropTypes.node, +}; + +const propTypes = { + datasource: PropTypes.object.isRequired, + onChange: PropTypes.func, + addSuccessToast: PropTypes.func.isRequired, + addDangerToast: PropTypes.func.isRequired, +}; +const defaultProps = { + onChange: () => {}, +}; +export class DatasourceEditor extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + datasource: props.datasource, + showAlert: true, + errors: [], + isDruid: props.datasource.type === 'druid', + isSqla: props.datasource.type === 'table', + databaseColumns: props.datasource.columns.filter(col => !col.expression), + calculatedColumns: props.datasource.columns.filter(col => !!col.expression), + metadataLoading: false, + activeTabKey: 1, + }; + + this.onChange = this.onChange.bind(this); + this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this); + this.onDatasourceChange = this.onDatasourceChange.bind(this); + this.hideAlert = this.hideAlert.bind(this); + this.syncMetadata = this.syncMetadata.bind(this); + this.setColumns = this.setColumns.bind(this); + this.validateAndChange = this.validateAndChange.bind(this); + this.handleTabSelect = this.handleTabSelect.bind(this); + } + onChange() { + const datasource = { + ...this.state.datasource, + columns: [...this.state.databaseColumns, ...this.state.calculatedColumns], + }; + this.props.onChange(datasource, this.state.errors); + } + onDatasourceChange(newDatasource) { + this.setState({ datasource: newDatasource }, this.validateAndChange); + } + onDatasourcePropChange(attr, value) { + const datasource = { ...this.state.datasource, [attr]: value }; + this.setState({ datasource }, this.onDatasourceChange(datasource)); + } + setColumns(obj) { + this.setState(obj, this.validateAndChange); + } + validateAndChange() { + this.validate(this.onChange); + } + mergeColumns(cols) { + let { databaseColumns } = this.state; + let hasChanged; + const currentColNames = databaseColumns.map(col => col.column_name); + cols.forEach((col) => { + if (currentColNames.indexOf(col.name) < 0) { + // Adding columns + databaseColumns = databaseColumns.concat([{ + id: shortid.generate(), + column_name: col.name, + type: col.type, + groupby: true, + filterable: true, + }]); + hasChanged = true; + } + }); + if (hasChanged) { + this.setColumns({ databaseColumns }); + } + } + syncMetadata() { + const datasource = this.state.datasource; + const url = `/datasource/external_metadata/${datasource.type}/${datasource.id}/`; + this.setState({ metadataLoading: true }); + const success = (data) => { + this.mergeColumns(data); + this.props.addSuccessToast(t('Metadata has been synced')); + this.setState({ metadataLoading: false }); + }; + const error = (err) => { + let msg = t('An error has occurred'); + if (err.responseJSON && err.responseJSON.error) { + msg = err.responseJSON.error; + } + this.props.addDangerToast(msg); + this.setState({ metadataLoading: false }); + }; + $.ajax({ + url, + type: 'GET', + success, + error, + }); + } + findDuplicates(arr, accessor) { + const seen = {}; + const dups = []; + arr.forEach((obj) => { + const item = accessor(obj); + if (item in seen) { + dups.push(item); + } else { + seen[item] = null; + } + }); + return dups; + } + validate(callback) { + let errors = []; + let dups; + const datasource = this.state.datasource; + + // Looking for duplicate column_name + dups = this.findDuplicates(datasource.columns, obj => obj.column_name); + errors = errors.concat(dups.map(name => t('Column name [%s] is duplicated', name))); + + // Looking for duplicate metric_name + dups = this.findDuplicates(datasource.metrics, obj => obj.metric_name); + errors = errors.concat(dups.map(name => t('Metric name [%s] is duplicated', name))); + + // Making sure calculatedColumns have an expression defined + const noFilterCalcCols = this.state.calculatedColumns.filter( + col => !col.expression && !col.json); + errors = errors.concat(noFilterCalcCols.map( + col => t('Calculated column [%s] requires an expression', col.column_name))); + + this.setState({ errors }, callback); + } + hideAlert() { + this.setState({ showAlert: false }); + } + handleTabSelect(activeTabKey) { + this.setState({ activeTabKey }); + } + renderSettingsFieldset() { + const datasource = this.state.datasource; + return ( + <Fieldset title="Basic" item={datasource} onChange={this.onDatasourceChange}> + <Field + fieldKey="description" + label={t('Description')} + control={<TextAreaControl language="markdown" offerEditInModal={false} />} + /> + <Field + fieldKey="default_endpoint" + label={t('Default URL')} + descr={t('Default URL to redirect to when accessing from the datasource list page')} + control={<TextControl />} + /> + <Field + fieldKey="filter_select" + label={t('Autocomplete filters')} + descr={t('Whether to populate autocomplete filters options')} + control={<CheckboxControl />} + /> + <Field + fieldKey="owner" + label={t('Owner')} + descr={t('Owner of the datasource')} + control={ + <SelectAsyncControl + dataEndpoint="/users/api/read" + multi={false} + mutator={data => data.pks.map((pk, i) => ({ + value: pk, + label: `${data.result[i].first_name} ${data.result[i].last_name}`, + }))} + />} + controlProps={{ + }} + /> + </Fieldset> + ); + } + renderAdvancedFieldset() { + const datasource = this.state.datasource; + return ( + <Fieldset title="Advanced" item={datasource} onChange={this.onDatasourceChange}> + { this.state.isSqla && + <Field + fieldKey="sql" + label={t('SQL')} + descr={t( + 'When specifying SQL, the datasource acts as a view. ' + + 'Superset will use this statement as a subquery while grouping and filtering ' + + 'on the generated parent queries.')} + control={<TextAreaControl language="sql" offerEditInModal={false} />} + /> + } + { this.state.isDruid && + <Field + fieldKey="json" + label={t('JSON')} + descr={ + <div> + {t('The JSON metric or post aggregation definition.')} + </div> + } + control={<TextAreaControl language="json" offerEditInModal={false} />} + /> + } + <Field + fieldKey="cache_timeout" + label={t('Cache Timeout')} + descr={t('The duration of time in seconds before the cache is invalidated')} + control={<TextControl />} + /> + <Field + fieldKey="offset" + label={t('Hours offset')} + control={<TextControl />} + /> + </Fieldset>); + } + renderSpatialTab() { + const spatials = this.state.datasource.spatials; + return ( + <Tab + title={<CollectionTabTitle collection={spatials} title={t('Spatial')} />} + eventKey={4} + > + <CollectionTable + tableColumns={['name', 'config']} + onChange={this.onDatasourcePropChange.bind(this, 'spatials')} + itemGenerator={() => ({ + name: '<new spatial>', + type: '<no type>', + config: null, + })} + collection={spatials} + allowDeletes + itemRenderers={{ + name: (d, onChange) => ( + <EditableTitle canEdit title={d} onSaveTitle={onChange} />), + config: (v, onChange) => ( + <SpatialControl value={v} onChange={onChange} choices={datasource.all_cols} /> + ), + }} + /> + </Tab>); + } + renderErrors() { + if (this.state.errors.length > 0) { + return ( + <Alert bsStyle="danger"> + {this.state.errors.map(err => <div key={err}>{err}</div>)} + </Alert>); + } + return null; + } + renderMetricCollection() { + return ( + <CollectionTable + tableColumns={['metric_name', 'verbose_name', 'expression']} + expandFieldset={ + <FormContainer> + <Fieldset> + <Field + fieldKey="description" + label={t('Description')} + control={<TextControl />} + /> + <Field + fieldKey="d3format" + label={t('D3 Format')} + control={<TextControl />} + /> + <Field + label={t('Warning Message')} + fieldKey="warning_text" + description={t('Warning message to display in the metric selector')} + control={<TextControl />} + /> + </Fieldset> + </FormContainer> + } + collection={this.state.datasource.metrics} + onChange={this.onDatasourcePropChange.bind(this, 'metrics')} + itemGenerator={() => ({ + metric_name: '<new metric>', + verbose_name: '', + expression: '', + })} + itemRenderers={{ + metric_name: (v, onChange) => ( + <EditableTitle canEdit title={v} onSaveTitle={onChange} />), + verbose_name: (v, onChange) => ( + <EditableTitle canEdit title={v} onSaveTitle={onChange} />), + expression: (v, onChange) => ( + <EditableTitle + canEdit + title={v} + onSaveTitle={onChange} + style={styleMonospace} + />), + description: (v, onChange, label) => ( + <StackedField + label={label} + formElement={<TextControl value={v} onChange={onChange} />} + />), + d3format: (v, onChange, label) => ( + <StackedField + label={label} + formElement={<TextControl value={v} onChange={onChange} />} + />), + }} + allowDeletes + />); + } + render() { + const datasource = this.state.datasource; + return ( + <div className="Datasource"> + {this.renderErrors()} + <Tabs + id="table-tabs" + onSelect={this.handleTabSelect} + defaultActiveKey={1} + > + <Tab eventKey={1} title={t('Settings')}> + {this.state.activeTabKey === 1 && + <div> + <Col md={6}> + <FormContainer> + {this.renderSettingsFieldset()} + </FormContainer> + </Col> + <Col md={6}> + <FormContainer> + {this.renderAdvancedFieldset()} + </FormContainer> + </Col> + </div> + } + </Tab> + <Tab + title={ + <CollectionTabTitle collection={this.state.databaseColumns} title={t('Columns')} /> + } + eventKey={2} + > + {this.state.activeTabKey === 2 && + <div> + <ColumnCollectionTable + columns={this.state.databaseColumns} + onChange={databaseColumns => this.setColumns({ databaseColumns })} + /> + <Button bsStyle="primary" onClick={this.syncMetadata} className="sync-from-source"> + {t('Sync columns from source')} + </Button> + {this.state.metadataLoading && <Loading />} + </div> + } + </Tab> + <Tab + title={ + <CollectionTabTitle + collection={this.state.calculatedColumns} + title={t('Calculated Columns')} + />} + eventKey={3} + > + {this.state.activeTabKey === 3 && + <ColumnCollectionTable + columns={this.state.calculatedColumns} + onChange={calculatedColumns => this.setColumns({ calculatedColumns })} + editableColumnName + showExpression + allowAddItem + allowEditDataType + itemGenerator={() => ({ + column_name: '<new column>', + filterable: true, + groupby: true, + expression: '<enter SQL expression here>', + __expanded: true, + })} + /> + } + </Tab> + <Tab + title={<CollectionTabTitle collection={datasource.metrics} title={t('Metrics')} />} + eventKey={4} + > + {this.state.activeTabKey === 4 && this.renderMetricCollection()} + </Tab> + </Tabs> + </div> + ); + } +} +DatasourceEditor.defaultProps = defaultProps; +DatasourceEditor.propTypes = propTypes; +export default withToasts(DatasourceEditor); diff --git a/superset/assets/src/datasource/DatasourceModal.jsx b/superset/assets/src/datasource/DatasourceModal.jsx new file mode 100644 index 0000000..cef993b --- /dev/null +++ b/superset/assets/src/datasource/DatasourceModal.jsx @@ -0,0 +1,161 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Button, Modal } from 'react-bootstrap'; +import Dialog from 'react-bootstrap-dialog'; + +import { t } from '../locales'; +import DatasourceEditor from '../datasource/DatasourceEditor'; +import withToasts from '../messageToasts/enhancers/withToasts'; + +const $ = window.$ = require('jquery'); + +const propTypes = { + onChange: PropTypes.func, + datasource: PropTypes.object, + show: PropTypes.bool.isRequired, + onHide: PropTypes.func, + onDatasourceSave: PropTypes.func, + addSuccessToast: PropTypes.func.isRequired, +}; + +const defaultProps = { + onChange: () => {}, + onHide: () => {}, + onDatasourceSave: () => {}, +}; + +class DatasourceModal extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showEditDatasource: false, + filter: '', + loading: true, + errors: [], + showDatasource: false, + datasource: props.datasource, + }; + this.toggleShowDatasource = this.toggleShowDatasource.bind(this); + this.changeSearch = this.changeSearch.bind(this); + this.setSearchRef = this.setSearchRef.bind(this); + this.onDatasourceChange = this.onDatasourceChange.bind(this); + this.onClickSave = this.onClickSave.bind(this); + this.onConfirmSave = this.onConfirmSave.bind(this); + } + onClickSave() { + this.dialog.show({ + title: 'Confirm save', + bsSize: 'medium', + actions: [ + Dialog.CancelAction(), + Dialog.OKAction(this.onConfirmSave), + ], + body: this.renderSaveDialog(), + }); + } + onConfirmSave() { + const url = '/datasource/save/'; + const that = this; + $.ajax({ + url, + type: 'POST', + data: { + data: JSON.stringify(this.state.datasource), + }, + success: (data) => { + this.props.addSuccessToast(t('The datasource has been saved')); + this.props.onDatasourceSave(data); + this.props.onHide(); + }, + error(err) { + let msg = t('An error has occurred'); + if (err.responseJSON && err.responseJSON.error) { + msg = err.responseJSON.error; + } + that.dialog.show({ + title: 'Error', + bsSize: 'medium', + bsStyle: 'danger', + actions: [ + Dialog.DefaultAction('Ok', () => {}, 'btn-danger'), + ], + body: msg, + }); + }, + }); + } + onDatasourceChange(datasource, errors) { + this.setState({ datasource, errors }); + } + setSearchRef(searchRef) { + this.searchRef = searchRef; + } + toggleShowDatasource() { + this.setState({ showDatasource: !this.state.showDatasource }); + } + changeSearch(event) { + this.setState({ filter: event.target.value }); + } + renderSaveDialog() { + return ( + <div> + <Alert bsStyle="warning" className="pointer" onClick={this.hideAlert}> + <div> + <i className="fa fa-exclamation-triangle" />{' '} + {t(`The data source configuration exposed here + affects all the charts using this datasource. + Be mindful that changing settings + here may affect other charts + in undesirable ways.`)} + </div> + </Alert> + {t('Are you sure you want to save and apply changes?')} + </div> + ); + } + render() { + return ( + <Modal + show={this.props.show} + onHide={this.props.onHide} + bsSize="lg" + > + <Modal.Header closeButton> + <Modal.Title> + <div> + <span className="float-left"> + {t('Datasource Editor for ')} + <strong>{this.props.datasource.name}</strong> + </span> + </div> + </Modal.Title> + </Modal.Header> + <Modal.Body> + {this.props.show && + <DatasourceEditor + datasource={this.props.datasource} + onChange={this.onDatasourceChange} + />} + </Modal.Body> + <Modal.Footer> + <span className="float-right"> + <Button + bsSize="sm" + bsStyle="primary" + className="m-r-5" + onClick={this.onClickSave} + disabled={this.state.errors.length > 0} + > + {t('Save')} + </Button> + <Button bsSize="sm" onClick={this.props.onHide}>{t('Cancel')}</Button> + <Dialog ref={(el) => { this.dialog = el; }} /> + </span> + </Modal.Footer> + </Modal>); + } +} + +DatasourceModal.propTypes = propTypes; +DatasourceModal.defaultProps = defaultProps; +export default withToasts(DatasourceModal); diff --git a/superset/assets/src/datasource/main.css b/superset/assets/src/datasource/main.css new file mode 100644 index 0000000..69fd97a --- /dev/null +++ b/superset/assets/src/datasource/main.css @@ -0,0 +1,4 @@ +.Datasource .tab-content { + height: 600px; + overflow: auto; +} diff --git a/superset/assets/src/explore/actions/exploreActions.js b/superset/assets/src/explore/actions/exploreActions.js index 81c23e3..b8a883e 100644 --- a/superset/assets/src/explore/actions/exploreActions.js +++ b/superset/assets/src/explore/actions/exploreActions.js @@ -1,6 +1,4 @@ /* eslint camelcase: 0 */ -import { triggerQuery } from '../../chart/chartAction'; - const $ = window.$ = require('jquery'); const FAVESTAR_BASE_URL = '/superset/favstar/slice'; @@ -20,21 +18,12 @@ export function setDatasources(datasources) { return { type: SET_DATASOURCES, datasources }; } -export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED'; -export function fetchDatasourceStarted() { - return { type: FETCH_DATASOURCE_STARTED }; -} - +export const POST_DATASOURCE_STARTED = 'POST_DATASOURCE_STARTED'; export const FETCH_DATASOURCE_SUCCEEDED = 'FETCH_DATASOURCE_SUCCEEDED'; export function fetchDatasourceSucceeded() { return { type: FETCH_DATASOURCE_SUCCEEDED }; } -export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED'; -export function fetchDatasourceFailed(error) { - return { type: FETCH_DATASOURCE_FAILED, error }; -} - export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED'; export function fetchDatasourcesStarted() { return { type: FETCH_DATASOURCES_STARTED }; @@ -45,9 +34,9 @@ export function fetchDatasourcesSucceeded() { return { type: FETCH_DATASOURCES_SUCCEEDED }; } -export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED'; -export function fetchDatasourcesFailed(error) { - return { type: FETCH_DATASOURCES_FAILED, error }; +export const POST_DATASOURCES_FAILED = 'POST_DATASOURCES_FAILED'; +export function postDatasourcesFailed(error) { + return { type: POST_DATASOURCES_FAILED, error }; } export const RESET_FIELDS = 'RESET_FIELDS'; @@ -55,28 +44,6 @@ export function resetControls() { return { type: RESET_FIELDS }; } -export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) { - return function (dispatch) { - dispatch(fetchDatasourceStarted()); - const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`; - $.ajax({ - type: 'GET', - url, - success: (data) => { - dispatch(setDatasource(data)); - dispatch(fetchDatasourceSucceeded()); - dispatch(resetControls()); - if (alsoTriggerQuery) { - dispatch(triggerQuery()); - } - }, - error(error) { - dispatch(fetchDatasourceFailed(error.responseJSON.error)); - }, - }); - }; -} - export function fetchDatasources() { return function (dispatch) { dispatch(fetchDatasourcesStarted()); diff --git a/superset/assets/src/explore/components/ControlPanelsContainer.jsx b/superset/assets/src/explore/components/ControlPanelsContainer.jsx index ff9754b..0eac0cf 100644 --- a/superset/assets/src/explore/components/ControlPanelsContainer.jsx +++ b/superset/assets/src/explore/components/ControlPanelsContainer.jsx @@ -44,7 +44,7 @@ class ControlPanelsContainer extends React.Component { } // Applying mapStateToProps if needed if (mapF) { - return Object.assign({}, control, mapF(this.props.exploreState, control)); + return Object.assign({}, control, mapF(this.props.exploreState, control, this.props.actions)); } return control; } diff --git a/superset/assets/src/explore/components/controls/CheckboxControl.jsx b/superset/assets/src/explore/components/controls/CheckboxControl.jsx index a4138e4..4c15c37 100644 --- a/superset/assets/src/explore/components/controls/CheckboxControl.jsx +++ b/superset/assets/src/explore/components/controls/CheckboxControl.jsx @@ -4,7 +4,7 @@ import ControlHeader from '../ControlHeader'; import Checkbox from '../../../components/Checkbox'; const propTypes = { - name: PropTypes.string.isRequired, + name: PropTypes.string, value: PropTypes.bool, label: PropTypes.string, description: PropTypes.string, @@ -22,21 +22,25 @@ export default class CheckboxControl extends React.Component { onChange() { this.props.onChange(!this.props.value); } - render() { + renderCheckbox() { return ( - <ControlHeader - {...this.props} - leftNode={ - <Checkbox - onChange={this.onChange.bind(this)} - style={checkboxStyle} - checked={!!this.props.value} - /> - } - /> - ); + <Checkbox + onChange={this.onChange.bind(this)} + style={checkboxStyle} + checked={!!this.props.value} + />); + } + render() { + if (this.props.label) { + return ( + <ControlHeader + {...this.props} + leftNode={this.renderCheckbox()} + /> + ); + } + return this.renderCheckbox(); } } - CheckboxControl.propTypes = propTypes; CheckboxControl.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx index c8391f2..5b616f1 100644 --- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx +++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx @@ -1,56 +1,49 @@ /* eslint no-undef: 2 */ import React from 'react'; import PropTypes from 'prop-types'; -import { Table } from 'reactable'; import { - Row, Col, Collapse, Label, - FormControl, - Modal, OverlayTrigger, + Row, Tooltip, Well, } from 'react-bootstrap'; import $ from 'jquery'; import ControlHeader from '../ControlHeader'; -import Loading from '../../../components/Loading'; import { t } from '../../../locales'; +import DatasourceModal from '../../../datasource/DatasourceModal'; import ColumnOption from '../../../components/ColumnOption'; import MetricOption from '../../../components/MetricOption'; import withToasts from '../../../messageToasts/enhancers/withToasts'; const propTypes = { - description: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string.isRequired, onChange: PropTypes.func, value: PropTypes.string.isRequired, - datasource: PropTypes.object, addDangerToast: PropTypes.func.isRequired, + datasource: PropTypes.object.isRequired, + onDatasourceSave: PropTypes.func, }; const defaultProps = { onChange: () => {}, + onDatasourceSave: () => {}, }; class DatasourceControl extends React.PureComponent { constructor(props) { super(props); this.state = { - showModal: false, + showEditDatasourceModal: false, filter: '', loading: true, showDatasource: false, }; this.toggleShowDatasource = this.toggleShowDatasource.bind(this); - this.onChange = this.onChange.bind(this); - this.onEnterModal = this.onEnterModal.bind(this); - this.toggleModal = this.toggleModal.bind(this); - this.changeSearch = this.changeSearch.bind(this); + this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this); this.setSearchRef = this.setSearchRef.bind(this); this.selectDatasource = this.selectDatasource.bind(this); } @@ -110,47 +103,10 @@ class DatasourceControl extends React.PureComponent { this.setState({ showModal: false }); this.props.onChange(datasourceId); } + toggleEditDatasourceModal() { + this.setState({ showEditDatasourceModal: !this.state.showEditDatasourceModal }); + } renderModal() { - return ( - <Modal - show={this.state.showModal} - onHide={this.toggleModal} - onEnter={this.onEnterModal} - onExit={this.setSearchRef} - bsSize="lg" - > - <Modal.Header closeButton> - <Modal.Title>{t('Select a datasource')}</Modal.Title> - </Modal.Header> - <Modal.Body> - <div> - <FormControl - id="formControlsText" - inputRef={(ref) => { - this.setSearchRef(ref); - }} - type="text" - bsSize="sm" - value={this.state.filter} - placeholder={t('Search / Filter')} - onChange={this.changeSearch} - /> - </div> - {this.state.loading && <Loading />} - {this.state.datasources && ( - <Table - columns={['name', 'type', 'schema', 'connection', 'creator']} - className="table table-condensed" - data={this.state.datasources} - itemsPerPage={20} - filterable={['rawName', 'type', 'connection', 'schema', 'creator']} - filterBy={this.state.filter} - hideFilterInput - /> - )} - </Modal.Body> - </Modal> - ); } renderDatasource() { const datasource = this.props.datasource; @@ -195,28 +151,18 @@ class DatasourceControl extends React.PureComponent { <Tooltip id={'error-tooltip'}>{t('Click to point to another datasource')}</Tooltip> } > - <Label onClick={this.toggleModal} style={{ cursor: 'pointer' }} className="m-r-5"> + <Label onClick={this.toggleEditDatasourceModal} style={{ cursor: 'pointer' }} className="m-r-5"> {this.props.datasource.name} </Label> </OverlayTrigger> <OverlayTrigger placement="right" overlay={ - <Tooltip id={'edit-datasource-tooltip'}> - {t("Edit the datasource's configuration")} + <Tooltip id={'toggle-datasource-tooltip'}> + {t('Expand/collapse datasource configuration')} </Tooltip> } > - <a href={this.props.datasource.edit_url}> - <i className="fa fa-edit m-r-5" /> - </a> - </OverlayTrigger> - <OverlayTrigger - placement="right" - overlay={ - <Tooltip id={'toggle-datasource-tooltip'}>{t('Show datasource configuration')}</Tooltip> - } - > <a href="#"> <i className={`fa fa-${this.state.showDatasource ? 'minus' : 'plus'}-square m-r-5`} @@ -238,7 +184,12 @@ class DatasourceControl extends React.PureComponent { </a> </OverlayTrigger>} <Collapse in={this.state.showDatasource}>{this.renderDatasource()}</Collapse> - {this.renderModal()} + <DatasourceModal + datasource={this.props.datasource} + show={this.state.showEditDatasourceModal} + onDatasourceSave={this.props.onDatasourceSave} + onHide={this.toggleEditDatasourceModal} + /> </div> ); } diff --git a/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx b/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx index ef8c417..fc8fb3c 100644 --- a/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx +++ b/superset/assets/src/explore/components/controls/SelectAsyncControl.jsx @@ -33,8 +33,15 @@ const defaultProps = { const SelectAsyncControl = (props) => { const { value, onChange, dataEndpoint, multi, mutator, placeholder, onAsyncErrorMessage } = props; const onSelectionChange = (options) => { - const optionValues = options.map(option => option.value); - onChange(optionValues); + let val; + if (multi) { + val = options.map(option => option.value); + } else if (options) { + val = options.value; + } else { + val = null; + } + onChange(val); }; return ( diff --git a/superset/assets/src/explore/components/controls/TextAreaControl.jsx b/superset/assets/src/explore/components/controls/TextAreaControl.jsx index 11c77a2..4a500ea 100644 --- a/superset/assets/src/explore/components/controls/TextAreaControl.jsx +++ b/superset/assets/src/explore/components/controls/TextAreaControl.jsx @@ -16,7 +16,7 @@ import ModalTrigger from '../../../components/ModalTrigger'; import { t } from '../../../locales'; const propTypes = { - name: PropTypes.string.isRequired, + name: PropTypes.string, onChange: PropTypes.func, value: PropTypes.string, height: PropTypes.number, @@ -46,6 +46,7 @@ export default class TextAreaControl extends React.Component { this.props.onChange(value); } renderEditor(inModal = false) { + const value = this.props.value || ''; if (this.props.language) { return ( <AceEditor @@ -58,7 +59,7 @@ export default class TextAreaControl extends React.Component { width="100%" editorProps={{ $blockScrolling: true }} enableLiveAutocompletion - value={this.props.value} + value={value} readOnly={this.props.readOnly} /> ); @@ -69,7 +70,7 @@ export default class TextAreaControl extends React.Component { componentClass="textarea" placeholder={t('textarea')} onChange={this.onControlChange.bind(this)} - value={this.props.value} + value={value} disabled={this.props.readOnly} style={{ height: this.props.height }} /> diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index 6405c46..ddf0775 100644 --- a/superset/assets/src/explore/controls.jsx +++ b/superset/assets/src/explore/controls.jsx @@ -191,8 +191,9 @@ export const controls = { label: t('Datasource'), default: null, description: null, - mapStateToProps: state => ({ + mapStateToProps: (state, control, actions) => ({ datasource: state.datasource, + onDatasourceSave: actions ? actions.setDatasource : () => {}, }), }, diff --git a/superset/assets/src/explore/reducers/exploreReducer.js b/superset/assets/src/explore/reducers/exploreReducer.js index a2c531e..7d4c5d5 100644 --- a/superset/assets/src/explore/reducers/exploreReducer.js +++ b/superset/assets/src/explore/reducers/exploreReducer.js @@ -10,19 +10,19 @@ export default function exploreReducer(state = {}, action) { isStarred: action.isStarred, }; }, - [actions.FETCH_DATASOURCE_STARTED]() { + [actions.POST_DATASOURCE_STARTED]() { return { ...state, isDatasourceMetaLoading: true, }; }, - [actions.FETCH_DATASOURCE_SUCCEEDED]() { + [actions.POST_DATASOURCE_SUCCEEDED]() { return { ...state, isDatasourceMetaLoading: false, }; }, - [actions.FETCH_DATASOURCE_FAILED]() { + [actions.POST_DATASOURCE_FAILED]() { return { ...state, isDatasourceMetaLoading: false, @@ -30,7 +30,6 @@ export default function exploreReducer(state = {}, action) { }; }, [actions.SET_DATASOURCE]() { - return { ...state, datasource: action.datasource, diff --git a/superset/assets/src/profile/components/CreatedContent.jsx b/superset/assets/src/profile/components/CreatedContent.jsx index 6b9445e..00d0f9d 100644 --- a/superset/assets/src/profile/components/CreatedContent.jsx +++ b/superset/assets/src/profile/components/CreatedContent.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import TableLoader from './TableLoader'; +import TableLoader from '../../components/TableLoader'; import { t } from '../../locales'; const propTypes = { diff --git a/superset/assets/src/profile/components/Favorites.jsx b/superset/assets/src/profile/components/Favorites.jsx index d0a4a45..e0ef112 100644 --- a/superset/assets/src/profile/components/Favorites.jsx +++ b/superset/assets/src/profile/components/Favorites.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import TableLoader from './TableLoader'; +import TableLoader from '../../components/TableLoader'; import { t } from '../../locales'; const propTypes = { diff --git a/superset/assets/src/profile/components/RecentActivity.jsx b/superset/assets/src/profile/components/RecentActivity.jsx index 14cf7a8..0a36fda 100644 --- a/superset/assets/src/profile/components/RecentActivity.jsx +++ b/superset/assets/src/profile/components/RecentActivity.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import TableLoader from './TableLoader'; +import TableLoader from '../../components/TableLoader'; const propTypes = { user: PropTypes.object, diff --git a/superset/assets/stylesheets/less/cosmo/variables.less b/superset/assets/stylesheets/less/cosmo/variables.less index fb2abf6..bc6fef5 100644 --- a/superset/assets/stylesheets/less/cosmo/variables.less +++ b/superset/assets/stylesheets/less/cosmo/variables.less @@ -213,7 +213,7 @@ @input-height-small: (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2); //** `.form-group` margin -@form-group-margin-bottom: 15px; +@form-group-margin-bottom: 10px; @legend-color: @text-color; @legend-border-color: #e5e5e5; diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index 0e8ffad..566a481 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -469,3 +469,6 @@ g.annotation-container { height: 30px; padding-left: 10px; } +.align-right { + text-align: right; +} diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index 213f895..bee7e1c 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -50,6 +50,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): params = Column(String(1000)) perm = Column(String(1000)) + sql = None + owner = None + update_from_object_fields = None + @declared_attr def slices(self): return relationship( @@ -83,6 +87,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): return 'timestamp' @property + def datasource_name(self): + raise NotImplementedError() + + @property def connection(self): """String representing the context of the Datasource""" return None @@ -134,7 +142,7 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): def metrics_combo(self): return sorted( [ - (m.metric_name, m.verbose_name or m.metric_name) + (m.metric_name, m.verbose_name or m.metric_name or '') for m in self.metrics], key=lambda x: x[1]) @@ -174,22 +182,36 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): for o in self.columns }) return { - 'all_cols': utils.choicify(self.column_names), + # simple fields + 'id': self.id, 'column_formats': self.column_formats, + 'description': self.description, 'database': self.database.data, # pylint: disable=no-member - 'edit_url': self.url, + 'default_endpoint': self.default_endpoint, 'filter_select': self.filter_select_enabled, + 'name': self.name, + 'datasource_name': self.datasource_name, + 'type': self.type, + 'schema': self.schema, + 'offset': self.offset, + 'cache_timeout': self.cache_timeout, + 'params': self.params, + 'perm': self.perm, + + # sqla-specific + 'sql': self.sql, + + # computed fields + 'all_cols': utils.choicify(self.column_names), + 'columns': [o.data for o in self.columns], + 'edit_url': self.url, 'filterable_cols': utils.choicify(self.filterable_column_names), 'gb_cols': utils.choicify(self.groupby_column_names), - 'id': self.id, + 'metrics': [o.data for o in self.metrics], 'metrics_combo': self.metrics_combo, - 'name': self.name, 'order_by_choices': order_by_choices, - 'type': self.type, - 'metrics': [o.data for o in self.metrics], - 'columns': [o.data for o in self.columns], + 'owner': self.owner.id if self.owner else None, 'verbose_map': verbose_map, - 'schema': self.schema, 'select_star': self.select_star, } @@ -222,6 +244,10 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): values = None return values + def external_metadata(self): + """Returns column information from the external system""" + raise NotImplementedError() + def get_query_str(self, query_obj): """Returns a query as a string @@ -253,6 +279,68 @@ class BaseDatasource(AuditMixinNullable, ImportMixin): if col.column_name == column_name: return col + def get_fk_many_from_list( + self, object_list, fkmany, fkmany_class, key_attr): + """Update ORM one-to-many list from object list + + Used for syncing metrics and columns using the same code""" + + object_dict = {o.get(key_attr): o for o in object_list} + object_keys = [o.get(key_attr) for o in object_list] + + # delete fks that have been removed + fkmany = [o for o in fkmany if getattr(o, key_attr) in object_keys] + + # sync existing fks + for fk in fkmany: + obj = object_dict.get(getattr(fk, key_attr)) + for attr in fkmany_class.update_from_object_fields: + setattr(fk, attr, obj.get(attr)) + + # create new fks + new_fks = [] + orm_keys = [getattr(o, key_attr) for o in fkmany] + for obj in object_list: + key = obj.get(key_attr) + if key not in orm_keys: + del obj['id'] + orm_kwargs = {} + for k in obj: + if ( + k in fkmany_class.update_from_object_fields and + k in obj + ): + orm_kwargs[k] = obj[k] + new_obj = fkmany_class(**orm_kwargs) + new_fks.append(new_obj) + fkmany += new_fks + return fkmany + + def update_from_object(self, obj): + """Update datasource from a data structure + + The UI's table editor crafts a complex data structure that + contains most of the datasource's properties as well as + an array of metrics and columns objects. This method + receives the object from the UI and syncs the datasource to + match it. Since the fields are different for the different + connectors, the implementation uses ``update_from_object_fields`` + which can be defined for each connector and + defines which fields should be synced""" + for attr in self.update_from_object_fields: + setattr(self, attr, obj.get(attr)) + + self.user_id = obj.get('owner') + + # Syncing metrics + metrics = self.get_fk_many_from_list( + obj.get('metrics'), self.metrics, self.metric_class, 'metric_name') + self.metrics = metrics + + # Syncing columns + self.columns = self.get_fk_many_from_list( + obj.get('columns'), self.columns, self.column_class, 'column_name') + class BaseColumn(AuditMixinNullable, ImportMixin): """Interface for column""" @@ -315,9 +403,11 @@ class BaseColumn(AuditMixinNullable, ImportMixin): @property def data(self): attrs = ( - 'column_name', 'verbose_name', 'description', 'expression', - 'filterable', 'groupby', 'is_dttm', 'type') - return {s: getattr(self, s) for s in attrs} + 'id', 'column_name', 'verbose_name', 'description', 'expression', + 'filterable', 'groupby', 'is_dttm', 'type', + 'database_expression', 'python_date_format', + ) + return {s: getattr(self, s) for s in attrs if hasattr(self, s)} class BaseMetric(AuditMixinNullable, ImportMixin): @@ -359,6 +449,6 @@ class BaseMetric(AuditMixinNullable, ImportMixin): @property def data(self): attrs = ( - 'metric_name', 'verbose_name', 'description', 'expression', - 'warning_text') + 'id', 'metric_name', 'verbose_name', 'description', 'expression', + 'warning_text', 'd3format') return {s: getattr(self, s) for s in attrs} diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 0977c0c..0554dfa 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -98,6 +98,7 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin): export_fields = ('cluster_name', 'coordinator_host', 'coordinator_port', 'coordinator_endpoint', 'broker_host', 'broker_port', 'broker_endpoint', 'cache_timeout') + update_from_object_fields = export_fields export_children = ['datasources'] def __repr__(self): @@ -142,6 +143,11 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin): self.coordinator_host, self.coordinator_port) + '/status' return json.loads(requests.get(endpoint).text)['version'] + @property + @utils.memoized + def druid_version(self): + return self.get_druid_version() + def refresh_datasources( self, datasource_name=None, @@ -150,7 +156,6 @@ class DruidCluster(Model, AuditMixinNullable, ImportMixin): """Refresh metadata of all datasources in the cluster If ``datasource_name`` is specified, only that datasource is updated """ - self.druid_version = self.get_druid_version() ds_list = self.get_datasources() blacklist = conf.get('DRUID_DATA_SOURCE_BLACKLIST', []) ds_refresh = [] @@ -271,6 +276,7 @@ class DruidColumn(Model, BaseColumn): 'count_distinct', 'sum', 'avg', 'max', 'min', 'filterable', 'description', 'dimension_spec_json', 'verbose_name', ) + update_from_object_fields = export_fields export_parent = 'datasource' def __repr__(self): @@ -413,8 +419,9 @@ class DruidMetric(Model, BaseMetric): export_fields = ( 'metric_name', 'verbose_name', 'metric_type', 'datasource_id', - 'json', 'description', 'is_restricted', 'd3format', + 'json', 'description', 'is_restricted', 'd3format', 'warning_text', ) + update_from_object_fields = export_fields export_parent = 'datasource' @property @@ -481,6 +488,7 @@ class DruidDatasource(Model, BaseDatasource): 'datasource_name', 'is_hidden', 'description', 'default_endpoint', 'cluster_name', 'offset', 'cache_timeout', 'params', ) + update_from_object_fields = export_fields export_parent = 'cluster' export_children = ['columns', 'metrics'] @@ -520,6 +528,9 @@ class DruidDatasource(Model, BaseDatasource): '[{obj.cluster_name}].[{obj.datasource_name}]' '(id:{obj.id})').format(obj=self) + def update_from_object(self, obj): + return NotImplementedError() + @property def link(self): name = escape(self.datasource_name) @@ -1575,6 +1586,16 @@ class DruidDatasource(Model, BaseDatasource): .all() ) + def external_metadata(self): + self.merge_flag = True + return [ + { + 'name': k, + 'type': v.get('type'), + } + for k, v in self.latest_metadata().items() + ] + sa.event.listen(DruidDatasource, 'after_insert', set_perm) sa.event.listen(DruidDatasource, 'after_update', set_perm) diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 4a4e024..e654926 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -93,6 +93,9 @@ class TableColumn(Model, BaseColumn): 'filterable', 'expression', 'description', 'python_date_format', 'database_expression', ) + + update_from_object_fields = [ + s for s in export_fields if s not in ('table_id',)] export_parent = 'table' @property @@ -170,6 +173,7 @@ class TableColumn(Model, BaseColumn): return s or "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S.%f')) def get_metrics(self): + # TODO deprecate, this is not needed since MetricsControl metrics = [] M = SqlMetric # noqa quoted = self.column_name @@ -221,7 +225,9 @@ class SqlMetric(Model, BaseMetric): export_fields = ( 'metric_name', 'verbose_name', 'metric_type', 'table_id', 'expression', - 'description', 'is_restricted', 'd3format') + 'description', 'is_restricted', 'd3format', 'warning_text') + update_from_object_fields = list([ + s for s in export_fields if s not in ('table_id', )]) export_parent = 'table' @property @@ -281,6 +287,8 @@ class SqlaTable(Model, BaseDatasource): 'table_name', 'main_dttm_col', 'description', 'default_endpoint', 'database_id', 'offset', 'cache_timeout', 'schema', 'sql', 'params', 'template_params') + update_from_object_fields = [ + f for f in export_fields if f not in ('table_name', 'database_id')] export_parent = 'database' export_children = ['metrics', 'columns'] @@ -305,6 +313,10 @@ class SqlaTable(Model, BaseDatasource): return utils.markdown(self.description) @property + def datasource_name(self): + return self.table_name + + @property def link(self): name = escape(self.name) anchor = '<a target="_blank" href="{self.explore_url}">{name}</a>' @@ -363,6 +375,12 @@ class SqlaTable(Model, BaseDatasource): def sql_url(self): return self.database.sql_url + '?table_name=' + str(self.table_name) + def external_metadata(self): + cols = self.database.get_columns(self.table_name, schema=self.schema) + for col in cols: + col['type'] = '{}'.format(col['type']) + return cols + @property def time_column_grains(self): return { diff --git a/superset/data/__init__.py b/superset/data/__init__.py index a164111..6dfba43 100644 --- a/superset/data/__init__.py +++ b/superset/data/__init__.py @@ -296,7 +296,7 @@ def load_world_bank_health_n_pop(): "TUV", "IMY", "KNA", "ASM", "ADO", "AMA", "PLW", ], "op": "not in"}], - )), + )), Slice( slice_name="Rural Breakdown", viz_type='sunburst', diff --git a/superset/models/core.py b/superset/models/core.py index a02be60..51e11b1 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -646,6 +646,10 @@ class Database(Model, AuditMixinNullable, ImportMixin): return self.database_name @property + def url_object(self): + return make_url(self.sqlalchemy_uri_decrypted) + + @property def backend(self): url = make_url(self.sqlalchemy_uri_decrypted) return url.get_backend_name() diff --git a/superset/views/__init__.py b/superset/views/__init__.py index ab93a55..a95da8b 100644 --- a/superset/views/__init__.py +++ b/superset/views/__init__.py @@ -3,3 +3,4 @@ from . import base # noqa from . import core # noqa from . import sql_lab # noqa from . import annotations # noqa +from . import datasource # noqa diff --git a/superset/views/base.py b/superset/views/base.py index 8bcdee4..ec31d9b 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -21,7 +21,8 @@ from flask_babel import gettext as __ from flask_babel import lazy_gettext as _ import yaml -from superset import conf, security_manager, utils +from superset import conf, db, security_manager, utils +from superset.exceptions import SupersetSecurityException from superset.translations.utils import get_language_pack FRONTEND_CONF_KEYS = ( @@ -91,6 +92,13 @@ def get_user_roles(): class BaseSupersetView(BaseView): + + def json_response(self, obj, status=200): + return Response( + json.dumps(obj, default=utils.json_int_dttm_ser), + status=status, + mimetype='application/json') + def common_bootsrap_payload(self): """Common data always sent to the client""" messages = get_flashed_messages(with_categories=True) @@ -268,3 +276,49 @@ class CsvResponse(Response): Override Response to take into account csv encoding from config.py """ charset = conf.get('CSV_EXPORT').get('encoding', 'utf-8') + + +def check_ownership(obj, raise_if_false=True): + """Meant to be used in `pre_update` hooks on models to enforce ownership + + Admin have all access, and other users need to be referenced on either + the created_by field that comes with the ``AuditMixin``, or in a field + named ``owners`` which is expected to be a one-to-many with the User + model. It is meant to be used in the ModelView's pre_update hook in + which raising will abort the update. + """ + if not obj: + return False + + security_exception = SupersetSecurityException( + "You don't have the rights to alter [{}]".format(obj)) + + if g.user.is_anonymous(): + if raise_if_false: + raise security_exception + return False + roles = [r.name for r in get_user_roles()] + if 'Admin' in roles: + return True + session = db.create_scoped_session() + orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first() + + # Making a list of owners that works across ORM models + owners = [] + if hasattr(orig_obj, 'owners'): + owners += orig_obj.owners + if hasattr(orig_obj, 'owner'): + owners += [orig_obj.owner] + if hasattr(orig_obj, 'created_by'): + owners += [orig_obj.created_by] + + owner_names = [o.username for o in owners] + + if ( + g.user and hasattr(g.user, 'username') and + g.user.username in owner_names): + return True + if raise_if_false: + raise security_exception + else: + return False diff --git a/superset/views/core.py b/superset/views/core.py index 14c5469..ab0f686 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -39,7 +39,7 @@ from superset import ( ) from superset.connectors.connector_registry import ConnectorRegistry from superset.connectors.sqla.models import AnnotationDatasource, SqlaTable -from superset.exceptions import SupersetException, SupersetSecurityException +from superset.exceptions import SupersetException from superset.forms import CsvToDatabaseForm from superset.jinja_context import get_template_processor from superset.legacy import cast_form_data, update_time_range @@ -50,8 +50,10 @@ from superset.utils import ( merge_extra_filters, merge_request_params, QueryStatus, ) from .base import ( - api, BaseSupersetView, CsvResponse, DeleteMixin, - generate_download_headers, get_error_msg, get_user_roles, + api, BaseSupersetView, + check_ownership, + CsvResponse, DeleteMixin, + generate_download_headers, get_error_msg, json_error_response, SupersetFilter, SupersetModelView, YamlExportMixin, ) from .utils import bootstrap_user_data @@ -92,48 +94,6 @@ def is_owner(obj, user): return obj and user in obj.owners -def check_ownership(obj, raise_if_false=True): - """Meant to be used in `pre_update` hooks on models to enforce ownership - - Admin have all access, and other users need to be referenced on either - the created_by field that comes with the ``AuditMixin``, or in a field - named ``owners`` which is expected to be a one-to-many with the User - model. It is meant to be used in the ModelView's pre_update hook in - which raising will abort the update. - """ - if not obj: - return False - - security_exception = SupersetSecurityException( - "You don't have the rights to alter [{}]".format(obj)) - - if g.user.is_anonymous(): - if raise_if_false: - raise security_exception - return False - roles = (r.name for r in get_user_roles()) - if 'Admin' in roles: - return True - session = db.create_scoped_session() - orig_obj = session.query(obj.__class__).filter_by(id=obj.id).first() - owner_names = (user.username for user in orig_obj.owners) - if ( - hasattr(orig_obj, 'created_by') and - orig_obj.created_by and - orig_obj.created_by.username == g.user.username): - return True - if ( - hasattr(orig_obj, 'owners') and - g.user and - hasattr(g.user, 'username') and - g.user.username in owner_names): - return True - if raise_if_false: - raise security_exception - else: - return False - - class SliceFilter(SupersetFilter): def apply(self, query, func): # noqa if self.has_all_datasource_access(): @@ -768,12 +728,6 @@ appbuilder.add_view_no_menu(R) class Superset(BaseSupersetView): """The base views for Superset!""" - def json_response(self, obj, status=200): - return Response( - json.dumps(obj, default=utils.json_int_dttm_ser), - status=status, - mimetype='application/json') - @has_access_api @expose('/datasources/') def datasources(self): diff --git a/superset/views/datasource.py b/superset/views/datasource.py new file mode 100644 index 0000000..e00bfbb --- /dev/null +++ b/superset/views/datasource.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# pylint: disable=C,R,W +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import json + +from flask import request +from flask_appbuilder import expose +from flask_babel import gettext as __ + +from superset import appbuilder, db +from superset.connectors.connector_registry import ConnectorRegistry +from .base import BaseSupersetView, check_ownership, json_error_response + + +class Datasource(BaseSupersetView): + """Datasource-related views""" + @expose('/save/', methods=['POST']) + def save(self): + datasource = json.loads(request.form.get('data')) + datasource_id = datasource.get('id') + datasource_type = datasource.get('type') + orm_datasource = ConnectorRegistry.get_datasource( + datasource_type, datasource_id, db.session) + + if not check_ownership(orm_datasource, raise_if_false=False): + return json_error_response( + __( + 'You are not authorized to modify ' + 'this data source configuration'), + status='401', + ) + orm_datasource.update_from_object(datasource) + data = orm_datasource.data + db.session.commit() + return self.json_response(data) + + @expose('/external_metadata/<datasource_type>/<datasource_id>/') + def external_metadata(self, datasource_type=None, datasource_id=None): + """Gets column info from the source system""" + orm_datasource = ConnectorRegistry.get_datasource( + datasource_type, datasource_id, db.session) + return self.json_response(orm_datasource.external_metadata()) + + +appbuilder.add_view_no_menu(Datasource) diff --git a/tests/base_tests.py b/tests/base_tests.py index a5af0a1..35ac335 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -112,8 +112,12 @@ class SupersetTestCase(unittest.TestCase): session.commit() def get_table(self, table_id): - return db.session.query(SqlaTable).filter_by( - id=table_id).first() + return ( + db.session + .query(SqlaTable) + .filter_by(id=table_id) + .one() + ) def get_or_create(self, cls, criteria, session): obj = session.query(cls).filter_by(**criteria).first() @@ -137,8 +141,7 @@ class SupersetTestCase(unittest.TestCase): return slc def get_table_by_name(self, name): - return db.session.query(SqlaTable).filter_by( - table_name=name).first() + return db.session.query(SqlaTable).filter_by(table_name=name).one() def get_druid_ds_by_name(self, name): return db.session.query(DruidDatasource).filter_by( @@ -215,50 +218,3 @@ class SupersetTestCase(unittest.TestCase): if raise_on_error and 'error' in resp: raise Exception('run_sql failed') return resp - - def test_gamma_permissions(self): - def assert_can_read(view_menu): - self.assertIn(('can_show', view_menu), gamma_perm_set) - self.assertIn(('can_list', view_menu), gamma_perm_set) - - def assert_can_write(view_menu): - self.assertIn(('can_add', view_menu), gamma_perm_set) - self.assertIn(('can_download', view_menu), gamma_perm_set) - self.assertIn(('can_delete', view_menu), gamma_perm_set) - self.assertIn(('can_edit', view_menu), gamma_perm_set) - - def assert_cannot_write(view_menu): - self.assertNotIn(('can_add', view_menu), gamma_perm_set) - self.assertNotIn(('can_download', view_menu), gamma_perm_set) - self.assertNotIn(('can_delete', view_menu), gamma_perm_set) - self.assertNotIn(('can_edit', view_menu), gamma_perm_set) - self.assertNotIn(('can_save', view_menu), gamma_perm_set) - - def assert_can_all(view_menu): - assert_can_read(view_menu) - assert_can_write(view_menu) - - gamma_perm_set = set() - for perm in security_manager.find_role('Gamma').permissions: - gamma_perm_set.add((perm.permission.name, perm.view_menu.name)) - - # check read only perms - assert_can_read('TableModelView') - assert_cannot_write('DruidColumnInlineView') - - # make sure that user can create slices and dashboards - assert_can_all('SliceModelView') - assert_can_all('DashboardModelView') - - self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set) - self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set) - self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set) - self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set) - self.assertIn(('can_csv', 'Superset'), gamma_perm_set) - self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set) - self.assertIn(('can_explore', 'Superset'), gamma_perm_set) - self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set) - self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set) - self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set) - self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set) - self.assertIn(('can_slice', 'Superset'), gamma_perm_set) diff --git a/tests/datasource_tests.py b/tests/datasource_tests.py new file mode 100644 index 0000000..7da7d92 --- /dev/null +++ b/tests/datasource_tests.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""Unit tests for Superset""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import json + +from .base_tests import SupersetTestCase +from .fixtures.datasource import datasource_post + + +class DatasourceTests(SupersetTestCase): + + requires_examples = True + + def __init__(self, *args, **kwargs): + super(DatasourceTests, self).__init__(*args, **kwargs) + + def test_external_metadata(self): + self.login(username='admin') + tbl_id = self.get_table_by_name('birth_names').id + url = '/datasource/external_metadata/table/{}/'.format(tbl_id) + resp = self.get_json_resp(url) + col_names = {o.get('name') for o in resp} + self.assertEquals( + col_names, + {'sum_boys', 'num', 'gender', 'name', 'ds', 'state', 'sum_girls'}, + ) + + def compare_lists(self, l1, l2, key): + l2_lookup = {o.get(key): o for o in l2} + for obj1 in l1: + obj2 = l2_lookup.get(obj1.get(key)) + for k in obj1: + if k not in 'id' and obj1.get(k): + self.assertEquals(obj1.get(k), obj2.get(k)) + + def test_save(self): + self.login(username='admin') + tbl_id = self.get_table_by_name('birth_names').id + datasource_post['id'] = tbl_id + data = dict(data=json.dumps(datasource_post)) + resp = self.get_json_resp('/datasource/save/', data) + for k in datasource_post: + if k == 'columns': + self.compare_lists(datasource_post[k], resp[k], 'column_name') + elif k == 'metrics': + self.compare_lists(datasource_post[k], resp[k], 'metric_name') + else: + self.assertEquals(resp[k], datasource_post[k]) diff --git a/tests/druid_tests.py b/tests/druid_tests.py index 426fa9a..00df0f8 100644 --- a/tests/druid_tests.py +++ b/tests/druid_tests.py @@ -516,6 +516,21 @@ class DruidTests(SupersetTestCase): instance.timeseries.call_args[1]['granularity']['period'], ) + @patch('superset.connectors.druid.models.PyDruid') + def test_external_metadata(self, PyDruid): + self.login(username='admin') + self.login(username='admin') + cluster = self.get_cluster(PyDruid) + cluster.refresh_datasources() + datasource = cluster.datasources[0] + url = '/datasource/external_metadata/druid/{}/'.format(datasource.id) + resp = self.get_json_resp(url) + col_names = {o.get('name') for o in resp} + self.assertEquals( + col_names, + {'__time', 'dim1', 'dim2', 'metric1'}, + ) + if __name__ == '__main__': unittest.main() diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/datasource.py b/tests/fixtures/datasource.py new file mode 100644 index 0000000..9f0e47e --- /dev/null +++ b/tests/fixtures/datasource.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +"""Fixtures for test_datasource.py""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +datasource_post = { + 'id': None, + 'column_formats': {'ratio': '.2%'}, + 'description': 'Adding a DESCRip', + 'default_endpoint': '', + 'filter_select': True, + 'name': 'birth_names', + 'datasource_name': 'birth_names', + 'type': 'table', + 'schema': '', + 'offset': 66, + 'cache_timeout': 55, + 'sql': '', + 'columns': [ + { + 'id': 504, + 'column_name': 'ds', + 'verbose_name': '', + 'description': None, + 'expression': '', + 'filterable': True, + 'groupby': True, + 'is_dttm': True, + 'type': 'DATETIME', + 'database_expression': '', + }, + { + 'id': 505, + 'column_name': 'gender', + 'verbose_name': None, + 'description': None, + 'expression': '', + 'filterable': True, + 'groupby': True, + 'is_dttm': False, + 'type': 'VARCHAR(16)', + 'database_expression': None, + }, + { + 'id': 506, + 'column_name': 'name', + 'verbose_name': None, + 'description': None, + 'expression': None, + 'filterable': True, + 'groupby': True, + 'is_dttm': None, + 'type': 'VARCHAR(255)', + 'database_expression': None, + }, + { + 'id': 508, + 'column_name': 'state', + 'verbose_name': None, + 'description': None, + 'expression': None, + 'filterable': True, + 'groupby': True, + 'is_dttm': None, + 'type': 'VARCHAR(10)', + 'database_expression': None, + }, + { + 'id': 509, + 'column_name': 'sum_boys', + 'verbose_name': None, + 'description': None, + 'expression': None, + 'filterable': True, + 'groupby': True, + 'is_dttm': None, + 'type': 'BIGINT(20)', + 'database_expression': None, + }, + { + 'id': 510, + 'column_name': 'sum_girls', + 'verbose_name': None, + 'description': None, + 'expression': '', + 'filterable': False, + 'groupby': False, + 'is_dttm': False, + 'type': 'BIGINT(20)', + 'database_expression': None, + }, + { + 'id': 532, + 'column_name': 'num', + 'verbose_name': None, + 'description': None, + 'expression': None, + 'filterable': True, + 'groupby': True, + 'is_dttm': None, + 'type': 'BIGINT(20)', + 'database_expression': None, + }, + { + 'id': 522, + 'column_name': 'num_california', + 'verbose_name': None, + 'description': None, + 'expression': "CASE WHEN state = 'CA' THEN num ELSE 0 END", + 'filterable': False, + 'groupby': False, + 'is_dttm': False, + 'type': 'NUMBER', + 'database_expression': None, + }, + ], + 'metrics': [ + { + 'id': 824, + 'metric_name': 'sum__num', + 'verbose_name': 'Babies', + 'description': '', + 'expression': 'SUM(num)', + 'warning_text': '', + 'd3format': '', + }, + { + 'id': 836, + 'metric_name': 'count', + 'verbose_name': '', + 'description': None, + 'expression': 'count(1)', + 'warning_text': None, + 'd3format': None, + }, + { + 'id': 843, + 'metric_name': 'ratio', + 'verbose_name': 'Ratio Boys/Girls', + 'description': 'This represents the ratio of boys/girls', + 'expression': 'sum(sum_boys) / sum(sum_girls)', + 'warning_text': 'no warning', + 'd3format': '.2%', + }, + ], +} diff --git a/tests/security_tests.py b/tests/security_tests.py index 4e1597b..fe7564e 100644 --- a/tests/security_tests.py +++ b/tests/security_tests.py @@ -144,7 +144,7 @@ class RolePermissionTests(SupersetTestCase): self.assertTrue(security_manager.is_gamma_pvm( security_manager.find_permission_view_menu('can_show', 'TableModelView'))) - def test_gamma_permissions(self): + def test_gamma_permissions_basic(self): self.assert_can_gamma(get_perm_tuples('Gamma')) self.assert_cannot_gamma(get_perm_tuples('Gamma')) self.assert_cannot_alpha(get_perm_tuples('Alpha')) @@ -175,3 +175,50 @@ class RolePermissionTests(SupersetTestCase): self.assert_cannot_gamma(granter_set) self.assert_cannot_alpha(granter_set) + + def test_gamma_permissions(self): + def assert_can_read(view_menu): + self.assertIn(('can_show', view_menu), gamma_perm_set) + self.assertIn(('can_list', view_menu), gamma_perm_set) + + def assert_can_write(view_menu): + self.assertIn(('can_add', view_menu), gamma_perm_set) + self.assertIn(('can_download', view_menu), gamma_perm_set) + self.assertIn(('can_delete', view_menu), gamma_perm_set) + self.assertIn(('can_edit', view_menu), gamma_perm_set) + + def assert_cannot_write(view_menu): + self.assertNotIn(('can_add', view_menu), gamma_perm_set) + self.assertNotIn(('can_download', view_menu), gamma_perm_set) + self.assertNotIn(('can_delete', view_menu), gamma_perm_set) + self.assertNotIn(('can_edit', view_menu), gamma_perm_set) + self.assertNotIn(('can_save', view_menu), gamma_perm_set) + + def assert_can_all(view_menu): + assert_can_read(view_menu) + assert_can_write(view_menu) + + gamma_perm_set = set() + for perm in security_manager.find_role('Gamma').permissions: + gamma_perm_set.add((perm.permission.name, perm.view_menu.name)) + + # check read only perms + assert_can_read('TableModelView') + assert_cannot_write('DruidColumnInlineView') + + # make sure that user can create slices and dashboards + assert_can_all('SliceModelView') + assert_can_all('DashboardModelView') + + self.assertIn(('can_add_slices', 'Superset'), gamma_perm_set) + self.assertIn(('can_copy_dash', 'Superset'), gamma_perm_set) + self.assertIn(('can_created_dashboards', 'Superset'), gamma_perm_set) + self.assertIn(('can_created_slices', 'Superset'), gamma_perm_set) + self.assertIn(('can_csv', 'Superset'), gamma_perm_set) + self.assertIn(('can_dashboard', 'Superset'), gamma_perm_set) + self.assertIn(('can_explore', 'Superset'), gamma_perm_set) + self.assertIn(('can_explore_json', 'Superset'), gamma_perm_set) + self.assertIn(('can_fave_dashboards', 'Superset'), gamma_perm_set) + self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set) + self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set) + self.assertIn(('can_slice', 'Superset'), gamma_perm_set)