From 6a474cde6ae0d567c24dde2b6a74455390406d0f Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 10 Aug 2018 09:40:21 -0700 Subject: [PATCH] Landing page (cherry picked from commit 6eafd0bda82d663504a51e51f50648c6d510b4ba) (cherry picked from commit 26d893ee7e9704fb47f9265f6b00244ac4773cbe) (cherry picked from commit 1b4f75f6a4ed6c2a63c40422d6fa6ea04b11d4eb) (cherry picked from commit b4bd624723da681d8801125ed120bdba7f31496f) --- superset/assets/.eslintrc | 1 + superset/assets/package.json | 4 +- .../spec/javascripts/welcome/App_spec.jsx | 8 +- .../components/TemplateParamsEditor.jsx | 5 +- superset/assets/src/chart/chartAction.js | 21 +- superset/assets/src/components/ObjectTags.css | 195 +++++++++++ superset/assets/src/components/ObjectTags.jsx | 126 +++++++ .../components/BuilderComponentPane.jsx | 2 + .../src/dashboard/components/Header.jsx | 27 ++ .../components/gridComponents/Tags.jsx | 309 ++++++++++++++++++ .../components/gridComponents/index.js | 4 + .../components/gridComponents/new/NewTags.jsx | 16 + .../dashboard/util/componentIsResizable.js | 12 +- .../src/dashboard/util/componentTypes.js | 2 + .../assets/src/dashboard/util/constants.js | 8 + .../util/getDetailedComponentWidth.js | 4 +- .../assets/src/dashboard/util/isValidChild.js | 6 + .../src/dashboard/util/newComponentFactory.js | 2 + .../dashboard/util/shouldWrapChildInRow.js | 3 + .../explore/components/ExploreChartHeader.jsx | 152 ++++++--- superset/assets/src/modules/utils.js | 9 +- superset/assets/src/tags.js | 114 +++++++ superset/assets/src/utils/common.js | 9 +- superset/assets/src/welcome/App.jsx | 88 ++++- superset/assets/src/welcome/TagsTable.jsx | 94 ++++++ superset/assets/yarn.lock | 6 +- superset/config.py | 2 +- superset/migrations/versions/05cddfb1eea3_.py | 22 ++ superset/migrations/versions/179ef0f46016_.py | 22 ++ superset/migrations/versions/3eeec5640df9_.py | 22 ++ superset/migrations/versions/b3591036f8d4_.py | 22 ++ .../c82ee8a39623_add_implicit_tags.py | 187 +++++++++++ superset/models/core.py | 19 ++ superset/models/sql_lab.py | 11 + superset/models/tags.py | 229 +++++++++++++ superset/views/__init__.py | 5 +- superset/views/base.py | 4 + superset/views/core.py | 8 +- superset/views/tags.py | 196 +++++++++++ 39 files changed, 1881 insertions(+), 95 deletions(-) create mode 100644 superset/assets/src/components/ObjectTags.css create mode 100644 superset/assets/src/components/ObjectTags.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Tags.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx create mode 100644 superset/assets/src/tags.js create mode 100644 superset/assets/src/welcome/TagsTable.jsx create mode 100644 superset/migrations/versions/05cddfb1eea3_.py create mode 100644 superset/migrations/versions/179ef0f46016_.py create mode 100644 superset/migrations/versions/3eeec5640df9_.py create mode 100644 superset/migrations/versions/b3591036f8d4_.py create mode 100644 superset/migrations/versions/c82ee8a39623_add_implicit_tags.py create mode 100644 superset/models/tags.py create mode 100644 superset/views/tags.py diff --git a/superset/assets/.eslintrc b/superset/assets/.eslintrc index e49a4e0f1bbb4..a79d50d4d9db7 100644 --- a/superset/assets/.eslintrc +++ b/superset/assets/.eslintrc @@ -8,6 +8,7 @@ }, "globals": { "document": true, + "window": true, }, "rules": { "prefer-template": 0, diff --git a/superset/assets/package.json b/superset/assets/package.json index 0620e0952e820..b830080b44d34 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -108,6 +108,7 @@ "react-split-pane": "^0.1.66", "react-sticky": "^6.0.2", "react-syntax-highlighter": "^7.0.4", + "react-tag-autocomplete": "^5.5.1", "react-virtualized": "9.19.1", "react-virtualized-select": "^2.4.0", "reactable": "1.0.2", @@ -121,7 +122,8 @@ "supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40", "underscore": "^1.8.3", "urijs": "^1.18.10", - "viewport-mercator-project": "^5.0.0" + "viewport-mercator-project": "^5.0.0", + "whatwg-fetch": "^2.0.4" }, "devDependencies": { "babel-cli": "^6.14.0", diff --git a/superset/assets/spec/javascripts/welcome/App_spec.jsx b/superset/assets/spec/javascripts/welcome/App_spec.jsx index 46c6fdb90600f..408458ac28000 100644 --- a/superset/assets/spec/javascripts/welcome/App_spec.jsx +++ b/superset/assets/spec/javascripts/welcome/App_spec.jsx @@ -13,10 +13,10 @@ describe('App', () => { React.isValidElement(), ).to.equal(true); }); - it('renders 4 Tab, Panel, and Row components', () => { + it('renders Tab, Panel, and Row components', () => { const wrapper = shallow(); - expect(wrapper.find(Tab)).to.have.length(3); - expect(wrapper.find(Panel)).to.have.length(3); - expect(wrapper.find(Row)).to.have.length(3); + expect(wrapper.find(Tab)).to.have.length(4); + expect(wrapper.find(Panel)).to.have.length(4); + expect(wrapper.find(Row)).to.have.length(5); }); }); diff --git a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx index 8a3387ad54635..843735ec124b3 100644 --- a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx +++ b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx @@ -64,8 +64,9 @@ export default class TemplateParamsEditor extends React.Component { Assign a set of parameters as JSON below (example: {'{"my_table": "foo"}'}), and they become available - in your SQL (example: SELECT * FROM {'{{ my_table }}'} ) - by using  + in your SQL (example: SELECT * FROM {'{{ my_table }}'}) + by using + {' '} { - const redirectUrl = new URL(window.location); - redirectUrl.pathname = '/superset/sqllab'; - for (const k of redirectUrl.searchParams.keys()) { - redirectUrl.searchParams.delete(k); - } - redirectUrl.searchParams.set('datasourceKey', formData.datasource); - redirectUrl.searchParams.set('sql', response.query); - window.open(redirectUrl.href, '_blank'); + const redirectUrl = new URI(window.location); + redirectUrl + .pathname('/superset/sqllab') + .search({ datasourceKey: formData.datasource, sql: response.query }); + window.open(redirectUrl.href(), '_blank'); }, error: () => notify.error(t("The SQL couldn't be loaded")), }); diff --git a/superset/assets/src/components/ObjectTags.css b/superset/assets/src/components/ObjectTags.css new file mode 100644 index 0000000000000..4c4a8d5b57f42 --- /dev/null +++ b/superset/assets/src/components/ObjectTags.css @@ -0,0 +1,195 @@ +/** + *
+ *
+ * + *
+ *
+
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tags.jsx b/superset/assets/src/dashboard/components/gridComponents/Tags.jsx new file mode 100644 index 0000000000000..6763279c5e78f --- /dev/null +++ b/superset/assets/src/dashboard/components/gridComponents/Tags.jsx @@ -0,0 +1,309 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { ListGroup, ListGroupItem, Panel } from 'react-bootstrap'; +import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table'; +import moment from 'moment'; +import { unsafe } from 'reactable'; +import 'whatwg-fetch'; + +import DeleteComponentButton from '../DeleteComponentButton'; +import DragDroppable from '../dnd/DragDroppable'; +import HoverMenu from '../menu/HoverMenu'; +import IconButton from '../IconButton'; +import ResizableContainer from '../resizable/ResizableContainer'; +import SelectControl from '../../../explore/components/controls/SelectControl'; +import WithPopoverMenu from '../menu/WithPopoverMenu'; +import { componentShape } from '../../util/propShapes'; +import { fetchObjects, fetchSuggestions } from '../../../tags'; +import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes'; +import { + GRID_MIN_COLUMN_COUNT, + GRID_MIN_ROW_UNITS, + GRID_BASE_UNIT, + STANDARD_TAGS, + TAGGED_CONTENT_TYPES, +} from '../../util/constants'; + +const HEADER_HEIGHT = 48; + +const propTypes = { + id: PropTypes.string.isRequired, + parentId: PropTypes.string.isRequired, + component: componentShape.isRequired, + parentComponent: componentShape.isRequired, + index: PropTypes.number.isRequired, + depth: PropTypes.number.isRequired, + editMode: PropTypes.bool.isRequired, + + // grid related + availableColumnCount: PropTypes.number.isRequired, + columnWidth: PropTypes.number.isRequired, + onResizeStart: PropTypes.func.isRequired, + onResize: PropTypes.func.isRequired, + onResizeStop: PropTypes.func.isRequired, + + // dnd + deleteComponent: PropTypes.func.isRequired, + handleComponentDrop: PropTypes.func.isRequired, + updateComponents: PropTypes.func.isRequired, +}; + +const defaultProps = {}; + +function linkFormatter(cell, row) { + const url = `${cell}`; + return ( + + {row.name} + + ); +} + +function changedOnFormatter(cell) { + const date = new Date(cell); + return unsafe(moment.utc(date).fromNow()); +} + +class Tags extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + isFocused: false, + isConfiguring: false, + data: [], + tagSuggestions: STANDARD_TAGS, + }; + + this.handleChangeFocus = this.handleChangeFocus.bind(this); + this.handleDeleteComponent = this.handleDeleteComponent.bind(this); + this.toggleConfiguring = this.toggleConfiguring.bind(this); + this.handleUpdateMeta = this.handleUpdateMeta.bind(this); + this.handleChangeTags = this.handleUpdateMeta.bind(this, 'tags'); + this.handleChangeTypes = this.handleUpdateMeta.bind(this, 'types'); + + this.fetchResults = this.fetchResults.bind(this); + this.fetchTagSuggestions = this.fetchTagSuggestions.bind(this); + } + + componentDidMount() { + this.fetchResults(this.props.component); + this.fetchTagSuggestions(); + } + + handleChangeFocus(nextFocus) { + this.setState(() => ({ isFocused: nextFocus })); + } + + handleUpdateMeta(metaKey, nextValue) { + const { updateComponents, component } = this.props; + if (nextValue && component.meta[metaKey] !== nextValue) { + const nextComponent = { + ...component, + meta: { + ...component.meta, + [metaKey]: nextValue, + }, + }; + updateComponents({ [component.id]: nextComponent }); + this.fetchResults(nextComponent); + } + } + + fetchResults(component) { + const tags = component.meta.tags || []; + const types = component.meta.types || TAGGED_CONTENT_TYPES; + fetchObjects({ tags: tags.join(','), types: types.join(',') }, data => + this.setState({ data }), + ); + } + + fetchTagSuggestions() { + fetchSuggestions({ includeTypes: false }, suggestions => { + const tagSuggestions = STANDARD_TAGS.concat( + suggestions.map(tag => tag.name), + ); + this.setState({ tagSuggestions }); + }); + } + + handleDeleteComponent() { + const { deleteComponent, id, parentId } = this.props; + deleteComponent(id, parentId); + } + + toggleConfiguring() { + this.setState({ isConfiguring: !this.state.isConfiguring }); + } + + renderEditMode() { + const { component } = this.props; + return ( + + + + + + + + + + + ); + } + + renderPreviewMode() { + const component = this.props.component; + const height = component.meta.height * GRID_BASE_UNIT - HEADER_HEIGHT; + return ( + + + + Name + + + Type + + + Creator + + + Changed on + + + ); + } + + render() { + const { isFocused, isConfiguring } = this.state; + + const { + component, + parentComponent, + index, + depth, + availableColumnCount, + columnWidth, + onResizeStart, + onResize, + onResizeStop, + handleComponentDrop, + editMode, + } = this.props; + + // inherit the size of parent columns + const widthMultiple = + parentComponent.type === COLUMN_TYPE + ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT + : component.meta.width || GRID_MIN_COLUMN_COUNT; + + const buttonClass = isConfiguring ? 'fa fa-table' : 'fa fa-cog'; + + return ( + + {({ dropIndicatorProps, dragSourceRef }) => ( + +
+ +
+ {isConfiguring + ? this.renderEditMode() + : this.renderPreviewMode()} + {editMode && ( + + + + + )} +
+
+
+ {dropIndicatorProps &&
} + + )} + + ); + } +} + +Tags.propTypes = propTypes; +Tags.defaultProps = defaultProps; + +export default Tags; diff --git a/superset/assets/src/dashboard/components/gridComponents/index.js b/superset/assets/src/dashboard/components/gridComponents/index.js index c56bed01cdc4e..d775268f856fc 100644 --- a/superset/assets/src/dashboard/components/gridComponents/index.js +++ b/superset/assets/src/dashboard/components/gridComponents/index.js @@ -7,6 +7,7 @@ import { ROW_TYPE, TAB_TYPE, TABS_TYPE, + TAGS_TYPE, } from '../../util/componentTypes'; import ChartHolder from './ChartHolder'; @@ -17,6 +18,7 @@ import Header from './Header'; import Row from './Row'; import Tab from './Tab'; import Tabs from './Tabs'; +import Tags from './Tags'; export { default as ChartHolder } from './ChartHolder'; export { default as Markdown } from './Markdown'; @@ -26,6 +28,7 @@ export { default as Header } from './Header'; export { default as Row } from './Row'; export { default as Tab } from './Tab'; export { default as Tabs } from './Tabs'; +export { default as Tags } from './Tags'; export default { [CHART_TYPE]: ChartHolder, @@ -36,4 +39,5 @@ export default { [ROW_TYPE]: Row, [TAB_TYPE]: Tab, [TABS_TYPE]: Tabs, + [TAGS_TYPE]: Tags, }; diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx new file mode 100644 index 0000000000000..7ab41a7199ea2 --- /dev/null +++ b/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { TAGS_TYPE } from '../../../util/componentTypes'; +import { NEW_TAGS_ID } from '../../../util/constants'; +import DraggableNewComponent from './DraggableNewComponent'; + +export default function DraggableNewTags() { + return ( + + ); +} diff --git a/superset/assets/src/dashboard/util/componentIsResizable.js b/superset/assets/src/dashboard/util/componentIsResizable.js index 45812d762b58e..2d07902d61a25 100644 --- a/superset/assets/src/dashboard/util/componentIsResizable.js +++ b/superset/assets/src/dashboard/util/componentIsResizable.js @@ -1,5 +1,13 @@ -import { COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE } from './componentTypes'; +import { + COLUMN_TYPE, + CHART_TYPE, + MARKDOWN_TYPE, + TAGS_TYPE, +} from './componentTypes'; export default function componentIsResizable(entity) { - return [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE].indexOf(entity.type) > -1; + return ( + [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, TAGS_TYPE].indexOf(entity.type) > + -1 + ); } diff --git a/superset/assets/src/dashboard/util/componentTypes.js b/superset/assets/src/dashboard/util/componentTypes.js index 47478e6119d8b..25f363b7511ea 100644 --- a/superset/assets/src/dashboard/util/componentTypes.js +++ b/superset/assets/src/dashboard/util/componentTypes.js @@ -10,6 +10,7 @@ export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE'; export const ROW_TYPE = 'ROW'; export const TABS_TYPE = 'TABS'; export const TAB_TYPE = 'TAB'; +export const TAGS_TYPE = 'TAGS'; export default { CHART_TYPE, @@ -24,4 +25,5 @@ export default { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, }; diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js index b26cbff85fdbb..c8ca8ffaafea0 100644 --- a/superset/assets/src/dashboard/util/constants.js +++ b/superset/assets/src/dashboard/util/constants.js @@ -13,6 +13,7 @@ export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID'; export const NEW_ROW_ID = 'NEW_ROW_ID'; export const NEW_TAB_ID = 'NEW_TAB_ID'; export const NEW_TABS_ID = 'NEW_TABS_ID'; +export const NEW_TAGS_ID = 'NEW_TAGS_ID'; // grid constants export const DASHBOARD_ROOT_DEPTH = 0; @@ -41,6 +42,13 @@ export const UNDO_LIMIT = 50; export const SAVE_TYPE_OVERWRITE = 'overwrite'; export const SAVE_TYPE_NEWDASHBOARD = 'newDashboard'; +// objects that can be tagged +export const TAGGED_CONTENT_TYPES = ['dashboard', 'chart', 'query']; +export const STANDARD_TAGS = [ + ['owner:{{ current_user_id() }}', 'Owned by me'], + ['favorited_by:{{ current_user_id() }}', 'Favorited by me'], +]; + // default dashboard layout data size limit // could be overwritten by server-side config export const DASHBOARD_POSITION_DATA_LIMIT = 65535; diff --git a/superset/assets/src/dashboard/util/getDetailedComponentWidth.js b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js index ee3096d6710e7..88337560aa962 100644 --- a/superset/assets/src/dashboard/util/getDetailedComponentWidth.js +++ b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js @@ -5,6 +5,7 @@ import { COLUMN_TYPE, MARKDOWN_TYPE, CHART_TYPE, + TAGS_TYPE, } from './componentTypes'; function getTotalChildWidth({ id, components }) { @@ -67,7 +68,8 @@ export default function getDetailedComponentWidth({ }); } else if ( component.type === MARKDOWN_TYPE || - component.type === CHART_TYPE + component.type === CHART_TYPE || + component.type === TAGS_TYPE ) { result.minimumWidth = GRID_MIN_COLUMN_COUNT; } diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js index c975496baa40c..b11e0dc67b45a 100644 --- a/superset/assets/src/dashboard/util/isValidChild.js +++ b/superset/assets/src/dashboard/util/isValidChild.js @@ -25,6 +25,7 @@ import { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; import { DASHBOARD_ROOT_DEPTH as rootDepth } from './constants'; @@ -50,12 +51,14 @@ const parentMaxDepthLookup = { [HEADER_TYPE]: depthOne, [ROW_TYPE]: depthOne, [TABS_TYPE]: depthOne, + [TAGS_TYPE]: depthOne, }, [ROW_TYPE]: { [CHART_TYPE]: depthFour, [MARKDOWN_TYPE]: depthFour, [COLUMN_TYPE]: depthFour, + [TAGS_TYPE]: depthFour, }, [TABS_TYPE]: { @@ -70,6 +73,7 @@ const parentMaxDepthLookup = { [HEADER_TYPE]: depthTwo, [ROW_TYPE]: depthTwo, [TABS_TYPE]: depthTwo, + [TAGS_TYPE]: depthTwo, }, [COLUMN_TYPE]: { @@ -78,6 +82,7 @@ const parentMaxDepthLookup = { [MARKDOWN_TYPE]: depthFive, [ROW_TYPE]: depthThree, [DIVIDER_TYPE]: depthThree, + [TAGS_TYPE]: depthFive, }, // these have no valid children @@ -85,6 +90,7 @@ const parentMaxDepthLookup = { [DIVIDER_TYPE]: {}, [HEADER_TYPE]: {}, [MARKDOWN_TYPE]: {}, + [TAGS_TYPE]: {}, }; export default function isValidChild({ parentType, childType, parentDepth }) { diff --git a/superset/assets/src/dashboard/util/newComponentFactory.js b/superset/assets/src/dashboard/util/newComponentFactory.js index 18b433b0135eb..620235072c405 100644 --- a/superset/assets/src/dashboard/util/newComponentFactory.js +++ b/superset/assets/src/dashboard/util/newComponentFactory.js @@ -9,6 +9,7 @@ import { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; import { @@ -33,6 +34,7 @@ const typeToDefaultMetaData = { [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT }, [TABS_TYPE]: null, [TAB_TYPE]: { text: 'New Tab' }, + [TAGS_TYPE]: { width: 3, height: 30 }, }; function uuid(type) { diff --git a/superset/assets/src/dashboard/util/shouldWrapChildInRow.js b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js index e7e648cf1bf64..56a317b814bb7 100644 --- a/superset/assets/src/dashboard/util/shouldWrapChildInRow.js +++ b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js @@ -4,6 +4,7 @@ import { COLUMN_TYPE, MARKDOWN_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; const typeToWrapChildLookup = { @@ -11,12 +12,14 @@ const typeToWrapChildLookup = { [CHART_TYPE]: true, [COLUMN_TYPE]: true, [MARKDOWN_TYPE]: true, + [TAGS_TYPE]: true, }, [TAB_TYPE]: { [CHART_TYPE]: true, [COLUMN_TYPE]: true, [MARKDOWN_TYPE]: true, + [TAGS_TYPE]: true, }, }; diff --git a/superset/assets/src/explore/components/ExploreChartHeader.jsx b/superset/assets/src/explore/components/ExploreChartHeader.jsx index 8c9ea91f2a590..063444b859ff0 100644 --- a/superset/assets/src/explore/components/ExploreChartHeader.jsx +++ b/superset/assets/src/explore/components/ExploreChartHeader.jsx @@ -10,6 +10,13 @@ import FaveStar from '../../components/FaveStar'; import TooltipWrapper from '../../components/TooltipWrapper'; import Timer from '../../components/Timer'; import CachedLabel from '../../components/CachedLabel'; +import ObjectTags from '../../components/ObjectTags'; +import { + addTag, + deleteTag, + fetchSuggestions, + fetchTags, +} from '../../tags'; import { t } from '../../locales'; const CHART_STATUS_MAP = { @@ -32,9 +39,35 @@ const propTypes = { }; class ExploreChartHeader extends React.PureComponent { + constructor(props) { + super(props); + + this.fetchTags = fetchTags.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + includeTypes: false, + }); + this.fetchSuggestions = fetchSuggestions.bind(this, { + includeTypes: false, + }); + this.deleteTag = deleteTag.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + }); + this.addTag = addTag.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + includeTypes: false, + }); + } + runQuery() { - this.props.actions.runQuery(this.props.form_data, true, - this.props.timeout, this.props.chart.id); + this.props.actions.runQuery( + this.props.form_data, + true, + this.props.timeout, + this.props.chart.id, + ); } updateChartTitleOrSaveSlice(newTitle) { @@ -43,17 +76,23 @@ class ExploreChartHeader extends React.PureComponent { slice_name: newTitle, action: isNewSlice ? 'saveas' : 'overwrite', }; - this.props.actions.saveSlice(this.props.form_data, params) - .then((data) => { - if (isNewSlice) { - this.props.actions.createNewSlice( - data.can_add, data.can_download, data.can_overwrite, - data.slice, data.form_data); - this.props.addHistory({ isReplace: true, title: `[chart] ${data.slice.slice_name}` }); - } else { - this.props.actions.updateChartTitle(newTitle); - } - }); + this.props.actions.saveSlice(this.props.form_data, params).then((data) => { + if (isNewSlice) { + this.props.actions.createNewSlice( + data.can_add, + data.can_download, + data.can_overwrite, + data.slice, + data.form_data, + ); + this.props.addHistory({ + isReplace: true, + title: `[chart] ${data.slice.slice_name}`, + }); + } else { + this.props.actions.updateChartTitle(newTitle); + } + }); } renderChartTitle() { @@ -73,58 +112,69 @@ class ExploreChartHeader extends React.PureComponent { chartUpdateEndTime, chartUpdateStartTime, latestQueryFormData, - queryResponse } = this.props.chart; - const chartSucceeded = ['success', 'rendered'].indexOf(this.props.chart.chartStatus) > 0; + queryResponse, + } = this.props.chart; + const chartSucceeded = + ['success', 'rendered'].indexOf(this.props.chart.chartStatus) > 0; return ( -
+
- {this.props.slice && - - + {this.props.slice && ( + + - - - - - - - } - {this.props.chart.sliceFormData && + + + + + + )} + {this.props.chart.sliceFormData && ( - } + )} +
- {chartSucceeded && queryResponse && - } - {chartSucceeded && queryResponse && queryResponse.is_cached && - } + {chartSucceeded && + queryResponse && ( + + )} + {chartSucceeded && + queryResponse && + queryResponse.is_cached && ( + + )} { + if (response.ok) { + return response.json(); + } + throw new Error(response.text()); + }) + .then(json => callback( + json.filter(tag => tag.name.indexOf(':') === -1 || includeTypes))) + .catch(text => error(text)); +} + +export function fetchSuggestions(options, callback, error) { + const includeTypes = options.includeTypes !== undefined ? options.includeTypes : false; + window.fetch('/tagview/tags/suggestions/') + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(response.text()); + }) + .then(json => callback( + json.filter(tag => tag.name.indexOf(':') === -1 || includeTypes))) + .catch(text => error(text)); +} + +export function deleteTag(options, tag, callback, error) { + if (options.objectType === undefined || options.objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + const objectType = options.objectType; + const objectId = options.objectId; + + const url = `/tagview/tags/${objectType}/${objectId}/`; + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + body: JSON.stringify([tag]), + headers: { + 'content-type': 'application/json', + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + method: 'DELETE', + }) + .then((response) => { + if (response.ok) { + callback(response.text()); + } else { + error(response.text()); + } + }); +} + +export function addTag(options, tag, callback, error) { + if (options.objectType === undefined || options.objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + const objectType = options.objectType; + const objectId = options.objectId; + const includeTypes = options.includeTypes !== undefined ? options.includeTypes : false; + + if (tag.indexOf(':') !== -1 && !includeTypes) { + return; + } + const url = `/tagview/tags/${objectType}/${objectId}/`; + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + body: JSON.stringify([tag]), + headers: { + 'content-type': 'application/json', + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + method: 'POST', + }) + .then((response) => { + if (response.ok) { + callback(response.text()); + } else { + error(response.text()); + } + }); +} + +export function fetchObjects(options, callback) { + const tags = options.tags !== undefined ? options.tags : ''; + const types = options.types; + + let url = `/tagview/tagged_objects/?tags=${tags}`; + if (types) { + url += `&types=${types}`; + } + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + headers: { + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + }) + .then(response => response.json()) + .then(json => callback(json)); +} diff --git a/superset/assets/src/utils/common.js b/superset/assets/src/utils/common.js index c5bdfb9f6d53c..c14631b2e29e4 100644 --- a/superset/assets/src/utils/common.js +++ b/superset/assets/src/utils/common.js @@ -1,5 +1,6 @@ /* eslint global-require: 0 */ import $ from 'jquery'; +import URI from 'urijs'; const d3 = require('d3'); @@ -90,11 +91,9 @@ export function getShortUrl(longUrl, callback, onError) { } export function supersetURL(rootUrl, getParams = {}) { - const url = new URL(rootUrl, window.location.origin); - for (const k in getParams) { - url.searchParams.set(k, getParams[k]); - } - return url.href; + const parsedUrl = new URI(rootUrl).absoluteTo(window.location.origin); + parsedUrl.search(getParams); + return parsedUrl.href(); } export function isTruthy(obj) { diff --git a/superset/assets/src/welcome/App.jsx b/superset/assets/src/welcome/App.jsx index 5c694de928441..d82174e539563 100644 --- a/superset/assets/src/welcome/App.jsx +++ b/superset/assets/src/welcome/App.jsx @@ -1,10 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Panel, Row, Col, Tabs, Tab, FormControl } from 'react-bootstrap'; +import URI from 'urijs'; import RecentActivity from '../profile/components/RecentActivity'; import Favorites from '../profile/components/Favorites'; import DashboardTable from './DashboardTable'; +import SelectControl from '../explore/components/controls/SelectControl'; +import TagsTable from './TagsTable'; +import { fetchSuggestions } from '../tags'; import { t } from '../locales'; +import { STANDARD_TAGS } from '../dashboard/util/constants'; const propTypes = { user: PropTypes.object.isRequired, @@ -13,19 +18,60 @@ const propTypes = { export default class App extends React.PureComponent { constructor(props) { super(props); + + const parsedUrl = new URI(window.location); + const key = parsedUrl.fragment() || 'dashboards'; + const searchParams = parsedUrl.search(true); + const dashboardSearch = searchParams.search || ''; + const tagSearch = searchParams.tags || ''; this.state = { - search: '', + key, + dashboardSearch, + tagSearch, + tagSuggestions: STANDARD_TAGS, }; - this.onSearchChange = this.onSearchChange.bind(this); + + this.handleSelect = this.handleSelect.bind(this); + this.onDashboardSearchChange = this.onDashboardSearchChange.bind(this); + this.onTagSearchChange = this.onTagSearchChange.bind(this); + } + componentDidMount() { + fetchSuggestions({ includeTypes: false }, (suggestions) => { + const tagSuggestions = [ + ...STANDARD_TAGS, + ...suggestions.map(tag => tag.name), + ]; + this.setState({ tagSuggestions }); + }); + } + onDashboardSearchChange(event) { + const dashboardSearch = event.target.value; + this.setState({ dashboardSearch }, () => this.updateURL('search', dashboardSearch)); + } + onTagSearchChange(tags) { + const tagSearch = tags.join(','); + this.setState({ tagSearch }, () => this.updateURL('tags', tagSearch)); } - onSearchChange(event) { - this.setState({ search: event.target.value }); + updateURL(key, value) { + const parsedUrl = new URI(window.location); + parsedUrl.search(data => ({ ...data, [key]: value })); + window.history.pushState({ value }, value, parsedUrl.href()); + } + handleSelect(key) { + // store selected tab in URL + window.history.pushState({ tab: key }, key, `#${key}`); + + this.setState({ key }); } render() { return (
- - + +

{t('Dashboards')}

@@ -35,16 +81,16 @@ export default class App extends React.PureComponent { bsSize="sm" style={{ marginTop: '25px' }} placeholder="Search" - value={this.state.search} - onChange={this.onSearchChange} + value={this.state.dashboardSearch} + onChange={this.onDashboardSearchChange} />

- +
- +

{t('Recently Viewed')}

@@ -53,7 +99,7 @@ export default class App extends React.PureComponent {
- +

{t('Favorites')}

@@ -62,6 +108,26 @@ export default class App extends React.PureComponent {
+ + + +

{t('Tags')}

+
+ + + + + +
+ +
+
); diff --git a/superset/assets/src/welcome/TagsTable.jsx b/superset/assets/src/welcome/TagsTable.jsx new file mode 100644 index 0000000000000..15040a84905c9 --- /dev/null +++ b/superset/assets/src/welcome/TagsTable.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Table, Tr, Td, unsafe } from 'reactable'; +import 'whatwg-fetch'; + +import { fetchObjects } from '../tags'; +import Loading from '../components/Loading'; +import '../../stylesheets/reactable-pagination.css'; +import { t } from '../locales'; + +const propTypes = { + search: PropTypes.string, +}; + +const defaultProps = { + search: '', +}; + +export default class TagsTable extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + objects: false, + }; + this.fetchResults = this.fetchResults.bind(this); + } + componentDidMount() { + this.fetchResults(this.props.search); + } + componentWillReceiveProps(newProps) { + if (this.props.search !== newProps.search) { + this.fetchResults(newProps.search); + } + } + fetchResults(search) { + fetchObjects({ tags: search, types: null }, (data) => { + const objects = { dashboard: [], chart: [], query: [] }; + data.forEach((object) => { + objects[object.type].push(object); + }); + this.setState({ objects }); + }); + } + renderTable(type) { + return ( + + {this.state.objects[type].map(o => ( + + + + + ))} +
+ {o.name} + + {unsafe(o.creator)} + + {unsafe(moment.utc(o.changed_on).fromNow())} +
+ ); + } + render() { + if (this.state.objects) { + return ( +
+

{t('Dashboards')}

+ {this.renderTable('dashboard')} +
+

{t('Charts')}

+ {this.renderTable('chart')} +
+

{t('Queries')}

+ {this.renderTable('query')} +
+ ); + } + return ; + } +} + +TagsTable.propTypes = propTypes; +TagsTable.defaultProps = defaultProps; diff --git a/superset/assets/yarn.lock b/superset/assets/yarn.lock index 2f941b9301509..785e91c62ce98 100644 --- a/superset/assets/yarn.lock +++ b/superset/assets/yarn.lock @@ -8628,6 +8628,10 @@ react-syntax-highlighter@^7.0.4: prismjs "^1.8.4" refractor "^2.4.1" +react-tag-autocomplete@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/react-tag-autocomplete/-/react-tag-autocomplete-5.5.1.tgz#6b3f253d3d69eb546925118cdf43138a9aafe113" + react-test-renderer@^15.6.2: version "15.6.2" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-15.6.2.tgz#d0333434fc2c438092696ca770da5ed48037efa8" @@ -10715,7 +10719,7 @@ whatwg-encoding@^1.0.1: dependencies: iconv-lite "0.4.19" -whatwg-fetch@>=0.10.0: +whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" diff --git a/superset/config.py b/superset/config.py index a5e4f2988c4e1..d9106bbd01c73 100644 --- a/superset/config.py +++ b/superset/config.py @@ -415,7 +415,7 @@ class CeleryConfig(object): # The id of a template dashboard that should be copied to every new user DASHBOARD_TEMPLATE_ID = None -# A callable that allows altering the database conneciton URL and params +# A callable that allows altering the database connection URL and params # on the fly, at runtime. This allows for things like impersonation or # arbitrary logic. For instance you can wire different users to # use different connection parameters, or pass their email address as the diff --git a/superset/migrations/versions/05cddfb1eea3_.py b/superset/migrations/versions/05cddfb1eea3_.py new file mode 100644 index 0000000000000..17082c99c9f7c --- /dev/null +++ b/superset/migrations/versions/05cddfb1eea3_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 05cddfb1eea3 +Revises: ('b3591036f8d4', '0c5070e96b57') +Create Date: 2018-08-08 10:47:53.077871 + +""" + +# revision identifiers, used by Alembic. +revision = '05cddfb1eea3' +down_revision = ('b3591036f8d4', '0c5070e96b57') + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/179ef0f46016_.py b/superset/migrations/versions/179ef0f46016_.py new file mode 100644 index 0000000000000..41882d51892ca --- /dev/null +++ b/superset/migrations/versions/179ef0f46016_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 179ef0f46016 +Revises: ('1a1d627ebd8e', '05cddfb1eea3', '3eeec5640df9') +Create Date: 2018-08-22 21:41:21.478678 + +""" + +# revision identifiers, used by Alembic. +revision = '179ef0f46016' +down_revision = ('1a1d627ebd8e', '05cddfb1eea3', '3eeec5640df9') + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/3eeec5640df9_.py b/superset/migrations/versions/3eeec5640df9_.py new file mode 100644 index 0000000000000..fb7ffe95a9540 --- /dev/null +++ b/superset/migrations/versions/3eeec5640df9_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 3eeec5640df9 +Revises: None +Create Date: 2018-08-22 21:21:34.077734 + +""" + +# revision identifiers, used by Alembic. +revision = '3eeec5640df9' +down_revision = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/b3591036f8d4_.py b/superset/migrations/versions/b3591036f8d4_.py new file mode 100644 index 0000000000000..78f6b6f67d04e --- /dev/null +++ b/superset/migrations/versions/b3591036f8d4_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: b3591036f8d4 +Revises: ('7fcdcde0761c', 'c82ee8a39623') +Create Date: 2018-08-03 13:29:06.044516 + +""" + +# revision identifiers, used by Alembic. +revision = 'b3591036f8d4' +down_revision = ('7fcdcde0761c', 'c82ee8a39623') + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/superset/migrations/versions/c82ee8a39623_add_implicit_tags.py b/superset/migrations/versions/c82ee8a39623_add_implicit_tags.py new file mode 100644 index 0000000000000..1c46df478d0c1 --- /dev/null +++ b/superset/migrations/versions/c82ee8a39623_add_implicit_tags.py @@ -0,0 +1,187 @@ +"""Add implicit tags + +Revision ID: c82ee8a39623 +Revises: c18bd4186f15 +Create Date: 2018-07-26 11:10:23.653524 + +""" + +# revision identifiers, used by Alembic. +revision = 'c82ee8a39623' +down_revision = 'c18bd4186f15' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import Column, Enum, Integer, ForeignKey, String, Table +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship + +from superset import db +from superset.models.helpers import AuditMixinNullable +from superset.models.tags import ( + get_object_type, + get_tag, + ObjectTypes, + TagTypes, +) + + +Base = declarative_base() + + +class Tag(Base, AuditMixinNullable): + """A tag attached to an object (query, chart or dashboard).""" + __tablename__ = 'tag' + + id = Column(Integer, primary_key=True) + name = Column(String(250), unique=True) + type = Column(Enum(TagTypes)) + + +class TaggedObject(Base, AuditMixinNullable): + __tablename__ = 'tagged_object' + + id = Column(Integer, primary_key=True) + tag_id = Column(Integer, ForeignKey('tag.id')) + object_id = Column(Integer) + object_type = Column(Enum(ObjectTypes)) + + +class User(Base): + """Declarative class to do query in upgrade""" + __tablename__ = 'ab_user' + id = Column(Integer, primary_key=True) + + +slice_user = Table( + 'slice_user', + Base.metadata, + Column('id', Integer, primary_key=True), + Column('user_id', Integer, ForeignKey('ab_user.id')), + Column('slice_id', Integer, ForeignKey('slices.id')) +) + + +dashboard_user = Table( + 'dashboard_user', + Base.metadata, + Column('id', Integer, primary_key=True), + Column('user_id', Integer, ForeignKey('ab_user.id')), + Column('dashboard_id', Integer, ForeignKey('dashboards.id')) +) + + +class Slice(Base, AuditMixinNullable): + """Declarative class to do query in upgrade""" + __tablename__ = 'slices' + + id = Column(Integer, primary_key=True) + owners = relationship("User", secondary=slice_user) + + +class Dashboard(Base, AuditMixinNullable): + """Declarative class to do query in upgrade""" + __tablename__ = 'dashboards' + id = Column(Integer, primary_key=True) + owners = relationship("User", secondary=dashboard_user) + + +class SavedQuery(Base): + __tablename__ = 'saved_query' + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('ab_user.id')) + + +class Favstar(Base): + __tablename__ = 'favstar' + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('ab_user.id')) + class_name = Column(String(50)) + obj_id = Column(Integer) + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + Tag.__table__.create(bind) + TaggedObject.__table__.create(bind) + + # add type tags (eg, `type:dashboard` for dashboards) + for type in ObjectTypes.__members__: + session.add(Tag(name='type:{0}'.format(type), type=TagTypes.type)) + + # add owner tags (eg, `owner:1` for things owned by the admin) + for chart in session.query(Slice): + for owner in chart.owners: + name = 'owner:{0}'.format(owner.id) + tag = get_tag(name, session, TagTypes.owner) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=chart.id, + object_type=ObjectTypes.chart, + ) + session.add(tagged_object) + + tag = get_tag('type:chart', session, TagTypes.type) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=chart.id, + object_type=ObjectTypes.chart, + ) + session.add(tagged_object) + + for dashboard in session.query(Dashboard): + for owner in dashboard.owners: + name = 'owner:{0}'.format(owner.id) + tag = get_tag(name, session, TagTypes.owner) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=dashboard.id, + object_type=ObjectTypes.dashboard, + ) + session.add(tagged_object) + + tag = get_tag('type:dashboard', session, TagTypes.type) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=dashboard.id, + object_type=ObjectTypes.dashboard, + ) + session.add(tagged_object) + + for query in session.query(SavedQuery): + name = 'owner:{0}'.format(query.user_id) + tag = get_tag(name, session, TagTypes.owner) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=query.id, + object_type=ObjectTypes.query, + ) + session.add(tagged_object) + + tag = get_tag('type:query', session, TagTypes.type) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=query.id, + object_type=ObjectTypes.query, + ) + session.add(tagged_object) + + # add favorited_by tags + for star in session.query(Favstar): + name = 'favorited_by:{0}'.format(star.user_id) + tag = get_tag(name, session, TagTypes.favorited_by) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=star.obj_id, + object_type=get_object_type(star.class_name), + ) + session.add(tagged_object) + + session.commit() + + +def downgrade(): + op.drop_table('tag') + op.drop_table('tagged_object') diff --git a/superset/models/core.py b/superset/models/core.py index 9d9674c19560a..11916bb8cf4ee 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -40,6 +40,7 @@ from superset.connectors.connector_registry import ConnectorRegistry from superset.legacy import update_time_range from superset.models.helpers import AuditMixinNullable, ImportMixin +from superset.models.tags import ChartUpdater, DashboardUpdater, FavStarUpdater from superset.models.user_attributes import UserAttribute from superset.utils import MediumText from superset.viz import viz_types @@ -338,6 +339,13 @@ def import_obj(cls, slc_to_import, slc_to_override, import_time=None): session.flush() return slc_to_import.id + @property + def url(self): + return ( + '/superset/explore/?form_data=%7B%22slice_id%22%3A%20{0}%7D' + .format(self.id) + ) + sqla.event.listen(Slice, 'before_insert', set_related_perm) sqla.event.listen(Slice, 'before_update', set_related_perm) @@ -1119,3 +1127,14 @@ def user_roles(self): href = '{} Role'.format(r.name) action_list = action_list + '
  • ' + href + '
  • ' return '
      ' + action_list + '
    ' + + +# events for updating tags +sqla.event.listen(Slice, 'after_insert', ChartUpdater.after_insert) +sqla.event.listen(Slice, 'after_update', ChartUpdater.after_update) +sqla.event.listen(Slice, 'after_delete', ChartUpdater.after_delete) +sqla.event.listen(Dashboard, 'after_insert', DashboardUpdater.after_insert) +sqla.event.listen(Dashboard, 'after_update', DashboardUpdater.after_update) +sqla.event.listen(Dashboard, 'after_delete', DashboardUpdater.after_delete) +sqla.event.listen(FavStar, 'after_insert', FavStarUpdater.after_insert) +sqla.event.listen(FavStar, 'after_delete', FavStarUpdater.after_delete) diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 7e0ccb515cf11..1c024b47c1339 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -20,6 +20,7 @@ from superset import security_manager from superset.models.helpers import AuditMixinNullable +from superset.models.tags import QueryUpdater from superset.utils import QueryStatus, user_label install_aliases() @@ -154,3 +155,13 @@ def pop_tab_link(self): """.format(**locals())) + + @property + def url(self): + return '/superset/sqllab?savedQueryId={0}'.format(self.id) + + +# events for updating tags +sqla.event.listen(SavedQuery, 'after_insert', QueryUpdater.after_insert) +sqla.event.listen(SavedQuery, 'after_update', QueryUpdater.after_update) +sqla.event.listen(SavedQuery, 'after_delete', QueryUpdater.after_delete) diff --git a/superset/models/tags.py b/superset/models/tags.py new file mode 100644 index 0000000000000..eab9adfaf6eec --- /dev/null +++ b/superset/models/tags.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# pylint: disable=no-init +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import enum + +from flask_appbuilder import Model +from sqlalchemy import Column, Enum, ForeignKey, Integer, String +from sqlalchemy.orm import relationship, sessionmaker +from sqlalchemy.orm.exc import NoResultFound + +from superset.models.helpers import AuditMixinNullable + + +Session = sessionmaker(autoflush=False) + + +class TagTypes(enum.Enum): + + """ + Types for tags. + + Objects (queries, charts and dashboards) will have with implicit tags based + on metadata: types, owners and who favorited them. This way, user "alice" + can find all their objects by querying for the tag `owner:alice`. + """ + + # explicit tags, added manually by the owner + custom = 1 + + # implicit tags, generated automatically + type = 2 + owner = 3 + favorited_by = 4 + + +class ObjectTypes(enum.Enum): + + """Object types.""" + + query = 1 + chart = 2 + dashboard = 3 + + +class Tag(Model, AuditMixinNullable): + + """A tag attached to an object (query, chart or dashboard).""" + + __tablename__ = 'tag' + id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + name = Column(String(250), unique=True) + type = Column(Enum(TagTypes)) + + +class TaggedObject(Model, AuditMixinNullable): + + """An association between an object and a tag.""" + + __tablename__ = 'tagged_object' + id = Column(Integer, primary_key=True) # pylint: disable=invalid-name + tag_id = Column(Integer, ForeignKey('tag.id')) + object_id = Column(Integer) + object_type = Column(Enum(ObjectTypes)) + + tag = relationship('Tag') + + +def get_tag(name, session, type_): + try: + tag = session.query(Tag).filter_by(name=name, type=type_).one() + except NoResultFound: + tag = Tag(name=name, type=type_) + session.add(tag) + session.commit() + + return tag + + +def get_object_type(class_name): + mapping = { + 'slice': ObjectTypes.chart, + 'dashboard': ObjectTypes.dashboard, + 'query': ObjectTypes.query, + } + try: + return mapping[class_name.lower()] + except KeyError: + raise Exception('No mapping found for {0}'.format(class_name)) + + +class ObjectUpdater(object): + + object_type = None + + @classmethod + def get_owners_ids(cls, target): + raise NotImplementedError('Subclass should implement `get_owners_ids`') + + @classmethod + def _add_owners(cls, session, target): + for owner_id in cls.get_owners_ids(target): + name = 'owner:{0}'.format(owner_id) + tag = get_tag(name, session, TagTypes.owner) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=target.id, + object_type=ObjectTypes.chart, + ) + session.add(tagged_object) + + @classmethod + def after_insert(cls, mapper, connection, target): + # pylint: disable=unused-argument + session = Session(bind=connection) + + # add `owner:` tags + cls._add_owners(session, target) + + # add `type:` tags + tag = get_tag( + 'type:{0}'.format(cls.object_type), session, TagTypes.type) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=target.id, + object_type=ObjectTypes.query, + ) + session.add(tagged_object) + + session.commit() + + @classmethod + def after_update(cls, mapper, connection, target): + # pylint: disable=unused-argument + session = Session(bind=connection) + + # delete current `owner:` tags + query = session.query(TaggedObject.id).join(Tag).filter( + TaggedObject.object_type == cls.object_type, + TaggedObject.object_id == target.id, + Tag.type == TagTypes.owner, + ) + ids = [row[0] for row in query] + session.query(TaggedObject).filter( + TaggedObject.id.in_(ids)).delete( + synchronize_session=False) + + # add `owner:` tags + cls._add_owners(session, target) + + session.commit() + + @classmethod + def after_delete(cls, mapper, connection, target): + # pylint: disable=unused-argument + session = Session(bind=connection) + + # delete row from `tagged_objects` + session.query(TaggedObject).filter( + TaggedObject.object_type == cls.object_type, + TaggedObject.object_id == target.id, + ).delete() + + session.commit() + + +class ChartUpdater(ObjectUpdater): + + object_type = 'chart' + + @classmethod + def get_owners_ids(cls, target): + return [owner.id for owner in target.owners] + + +class DashboardUpdater(ObjectUpdater): + + object_type = 'dashboard' + + @classmethod + def get_owners_ids(cls, target): + return [owner.id for owner in target.owners] + + +class QueryUpdater(ObjectUpdater): + + object_type = 'query' + + @classmethod + def get_owners_ids(cls, target): + return [target.user_id] + + +class FavStarUpdater(object): + + @classmethod + def after_insert(cls, mapper, connection, target): + # pylint: disable=unused-argument + session = Session(bind=connection) + name = 'favorited_by:{0}'.format(target.user_id) + tag = get_tag(name, session, TagTypes.favorited_by) + tagged_object = TaggedObject( + tag_id=tag.id, + object_id=target.obj_id, + object_type=get_object_type(target.class_name), + ) + session.add(tagged_object) + + session.commit() + + @classmethod + def after_delete(cls, mapper, connection, target): + # pylint: disable=unused-argument + session = Session(bind=connection) + name = 'favorited_by:{0}'.format(target.user_id) + query = session.query(TaggedObject.id).join(Tag).filter( + TaggedObject.object_id == target.obj_id, + Tag.type == TagTypes.favorited_by, + Tag.name == name, + ) + ids = [row[0] for row in query] + session.query(TaggedObject).filter( + TaggedObject.id.in_(ids)).delete( + synchronize_session=False) + + session.commit() diff --git a/superset/views/__init__.py b/superset/views/__init__.py index ed9bffc5d381a..214aa1ab34208 100644 --- a/superset/views/__init__.py +++ b/superset/views/__init__.py @@ -3,5 +3,6 @@ from . import base # noqa from . import core # noqa from . import sql_lab # noqa -from . import annotations # noqa -from . import datasource # noqa +from . import datasource # noqa +from . import annotations # noqa +from . import tags # noqa diff --git a/superset/views/base.py b/superset/views/base.py index f24982083f861..a809f4d4b4d54 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -56,6 +56,10 @@ def json_error_response(msg=None, status=500, stacktrace=None, payload=None, lin status=status, mimetype='application/json') +def json_success(json_msg, status=200): + return Response(json_msg, status=status, mimetype='application/json') + + def generate_download_headers(extension, filename=None): filename = filename if filename else datetime.now().strftime('%Y%m%d_%H%M%S') content_disp = 'attachment; filename={}.{}'.format(filename, extension) diff --git a/superset/views/core.py b/superset/views/core.py index a816fbdf62a98..4b1f5f70533ea 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -55,7 +55,8 @@ check_ownership, CsvResponse, DeleteMixin, generate_download_headers, get_error_msg, - json_error_response, SupersetFilter, SupersetModelView, YamlExportMixin, + json_error_response, json_success, SupersetFilter, SupersetModelView, + YamlExportMixin, ) from .utils import bootstrap_user_data @@ -86,8 +87,9 @@ def get_database_access_error_msg(database_name): '`all_datasource_access` permission', name=database_name) -def json_success(json_msg, status=200): - return Response(json_msg, status=status, mimetype='application/json') +def get_datasource_access_error_msg(datasource_name): + return __('This endpoint requires the datasource %(name)s, database or ' + '`all_datasource_access` permission', name=datasource_name) def is_owner(obj, user): diff --git a/superset/views/tags.py b/superset/views/tags.py new file mode 100644 index 0000000000000..6974befa30fd3 --- /dev/null +++ b/superset/views/tags.py @@ -0,0 +1,196 @@ +# -*- 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 + +from flask import request, Response +from flask_appbuilder import expose +from jinja2.sandbox import SandboxedEnvironment +import simplejson as json +from sqlalchemy import and_, func +from werkzeug.routing import BaseConverter + +from superset import app, appbuilder, db, utils +from superset.jinja_context import current_user_id, current_username +import superset.models.core +from superset.models.sql_lab import SavedQuery +from superset.models.tags import ObjectTypes, Tag, TaggedObject, TagTypes +from .base import BaseSupersetView, json_success + + +class ObjectTypeConverter(BaseConverter): + + """Validate that object_type is indeed an object type.""" + + def to_python(self, object_type): + return ObjectTypes[object_type] + + def to_url(self, object_type): + return object_type.name + + +def process_template(content): + env = SandboxedEnvironment() + template = env.from_string(content) + context = { + 'current_user_id': current_user_id, + 'current_username': current_username, + } + return template.render(context) + + +def get_name(obj): + if obj.Dashboard: + return obj.Dashboard.dashboard_title + elif obj.Slice: + return obj.Slice.slice_name + elif obj.SavedQuery: + return obj.SavedQuery.label + + +def get_creator(obj): + if obj.Dashboard: + return obj.Dashboard.creator() + elif obj.Slice: + return obj.Slice.creator() + elif obj.SavedQuery: + return obj.SavedQuery.creator() + + +def get_attribute(obj, attr): + if obj.Dashboard: + return getattr(obj.Dashboard, attr) + elif obj.Slice: + return getattr(obj.Slice, attr) + elif obj.SavedQuery: + return getattr(obj.SavedQuery, attr) + + +class TagView(BaseSupersetView): + + @expose('/tags/suggestions/', methods=['GET']) + def suggestions(self): + query = ( + db.session.query(TaggedObject) + .group_by(TaggedObject.tag_id) + .order_by(func.count().desc()) + .all() + ) + tags = [{'id': obj.tag.id, 'name': obj.tag.name} for obj in query] + return json_success(json.dumps(tags)) + + @expose('/tags///', methods=['GET']) + def get(self, object_type, object_id): + """List all tags a given object has.""" + query = db.session.query(TaggedObject).filter(and_( + TaggedObject.object_type == object_type, + TaggedObject.object_id == object_id)) + tags = [{'id': obj.tag.id, 'name': obj.tag.name} for obj in query] + return json_success(json.dumps(tags)) + + @expose('/tags///', methods=['POST']) + def post(self, object_type, object_id): + """Add new tags to an object.""" + tagged_objects = [] + for name in request.get_json(force=True): + if ':' in name: + type_name = name.split(':', 1)[0] + type_ = TagTypes[type_name] + else: + type_ = TagTypes.custom + + tag = db.session.query(Tag).filter_by(name=name, type=type_).first() + if not tag: + tag = Tag(name=name, type=type_) + + tagged_objects.append( + TaggedObject( + object_id=object_id, + object_type=object_type, + tag=tag, + ), + ) + + db.session.add_all(tagged_objects) + db.session.commit() + + return Response(status=201) # 201 CREATED + + @expose('/tags///', methods=['DELETE']) + def delete(self, object_type, object_id): + """Remove tags from an object.""" + tag_names = request.get_json(force=True) + if not tag_names: + return Response(status=403) + + db.session.query(TaggedObject).filter(and_( + TaggedObject.object_type == object_type, + TaggedObject.object_id == object_id), + TaggedObject.tag.has(Tag.name.in_(tag_names)), + ).delete(synchronize_session=False) + db.session.commit() + + return Response(status=204) # 204 NO CONTENT + + @expose('/tagged_objects/', methods=['GET', 'POST']) + def tagged_objects(self): + query = db.session.query( + TaggedObject, + superset.models.core.Dashboard, + superset.models.core.Slice, + SavedQuery, + ).join(Tag) + + tags = request.args.get('tags') + if not tags: + return json_success(json.dumps([])) + + tags = [process_template(tag) for tag in tags.split(',')] + query = query.filter(Tag.name.in_(tags)) + + # filter types + types = request.args.get('types') + if types: + query = query.filter(TaggedObject.object_type.in_(types.split(','))) + + # get names + query = query.outerjoin( + superset.models.core.Dashboard, + and_( + TaggedObject.object_id == superset.models.core.Dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard, + ), + ).outerjoin( + superset.models.core.Slice, + and_( + TaggedObject.object_id == superset.models.core.Slice.id, + TaggedObject.object_type == ObjectTypes.chart, + ), + ).outerjoin( + SavedQuery, + and_( + TaggedObject.object_id == SavedQuery.id, + TaggedObject.object_type == ObjectTypes.query, + ), + ).group_by(TaggedObject.object_id, TaggedObject.object_type) + + objects = [ + { + 'id': get_attribute(obj, 'id'), + 'type': obj.TaggedObject.object_type.name, + 'name': get_name(obj), + 'url': get_attribute(obj, 'url'), + 'changed_on': get_attribute(obj, 'changed_on'), + 'created_by': get_attribute(obj, 'created_by_fk'), + 'creator': get_creator(obj), + } + for obj in query if get_attribute(obj, 'id') + ] + + return json_success(json.dumps(objects, default=utils.json_int_dttm_ser)) + + +app.url_map.converters['object_type'] = ObjectTypeConverter +appbuilder.add_view_no_menu(TagView)