From 7e1852ee883628d38b2e3bb71e2b2b03fad41ba3 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sat, 19 Nov 2016 21:23:44 -0800 Subject: [PATCH] User profile pages (favorites, created content, recent activity, security & access) (#1615) * Super * User profile page * Fixing python style * Python unit tests * Touchups and js tests * Addressing comments --- .../dashboard/components/Header.jsx | 5 +- .../javascripts/profile/components/App.jsx | 51 ++++ .../profile/components/CreatedContent.jsx | 67 ++++++ .../profile/components/Favorites.jsx | 64 ++++++ .../profile/components/RecentActivity.jsx | 44 ++++ .../profile/components/Security.jsx | 41 ++++ .../profile/components/TableLoader.jsx | 64 ++++++ .../profile/components/UserInfo.jsx | 48 ++++ superset/assets/javascripts/profile/index.jsx | 22 ++ superset/assets/javascripts/profile/main.css | 12 + superset/assets/package.json | 1 + .../spec/javascripts/profile/App_spec.jsx | 27 +++ .../profile/CreatedContent_spec.jsx | 27 +++ .../javascripts/profile/Favorites_spec.jsx | 26 +++ .../profile/RecentActivity_spec.jsx | 23 ++ .../javascripts/profile/Security_spec.jsx | 35 +++ .../javascripts/profile/UserInfo_spec.jsx | 40 ++++ .../spec/javascripts/profile/fixtures.jsx | 52 +++++ superset/assets/stylesheets/profile.css | 3 + superset/assets/webpack.config.js | 1 + superset/models.py | 6 +- superset/source_registry.py | 1 + superset/templates/appbuilder/navbar.html | 4 +- superset/templates/superset/basic.html | 8 +- .../superset/partials/_explore_title.html | 2 +- superset/templates/superset/profile.html | 8 + superset/views.py | 217 +++++++++++++++++- tests/core_tests.py | 16 ++ 28 files changed, 903 insertions(+), 12 deletions(-) create mode 100644 superset/assets/javascripts/profile/components/App.jsx create mode 100644 superset/assets/javascripts/profile/components/CreatedContent.jsx create mode 100644 superset/assets/javascripts/profile/components/Favorites.jsx create mode 100644 superset/assets/javascripts/profile/components/RecentActivity.jsx create mode 100644 superset/assets/javascripts/profile/components/Security.jsx create mode 100644 superset/assets/javascripts/profile/components/TableLoader.jsx create mode 100644 superset/assets/javascripts/profile/components/UserInfo.jsx create mode 100644 superset/assets/javascripts/profile/index.jsx create mode 100644 superset/assets/javascripts/profile/main.css create mode 100644 superset/assets/spec/javascripts/profile/App_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/Favorites_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/Security_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/UserInfo_spec.jsx create mode 100644 superset/assets/spec/javascripts/profile/fixtures.jsx create mode 100644 superset/assets/stylesheets/profile.css create mode 100644 superset/templates/superset/profile.html diff --git a/superset/assets/javascripts/dashboard/components/Header.jsx b/superset/assets/javascripts/dashboard/components/Header.jsx index bfedb4fd1cf5f..3f94fa3ebb4d2 100644 --- a/superset/assets/javascripts/dashboard/components/Header.jsx +++ b/superset/assets/javascripts/dashboard/components/Header.jsx @@ -19,7 +19,10 @@ class Header extends React.PureComponent { return (
-

{dashboard.dashboard_title}

+

+ {dashboard.dashboard_title}   + +

{!this.props.dashboard.context.standalone_mode && diff --git a/superset/assets/javascripts/profile/components/App.jsx b/superset/assets/javascripts/profile/components/App.jsx new file mode 100644 index 0000000000000..c12d092698d78 --- /dev/null +++ b/superset/assets/javascripts/profile/components/App.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Col, Row, Tabs, Tab, Panel } from 'react-bootstrap'; +import Favorites from './Favorites'; +import UserInfo from './UserInfo'; +import Security from './Security'; +import RecentActivity from './RecentActivity'; +import CreatedContent from './CreatedContent'; + +const propTypes = { + user: React.PropTypes.object.isRequired, +}; + +export default function App(props) { + return ( +
+ + + + + + + Favorites
}> + + + Created Content
+ } + > + + + + + Recent Activity
}> + + + + + Security & Access}> + + + + + + + + + ); +} +App.propTypes = propTypes; diff --git a/superset/assets/javascripts/profile/components/CreatedContent.jsx b/superset/assets/javascripts/profile/components/CreatedContent.jsx new file mode 100644 index 0000000000000..d779e19892178 --- /dev/null +++ b/superset/assets/javascripts/profile/components/CreatedContent.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import moment from 'moment'; +import TableLoader from './TableLoader'; + +const propTypes = { + user: React.PropTypes.object.isRequired, +}; + +class CreatedContent extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + dashboardsLoading: true, + slicesLoading: true, + dashboards: [], + slices: [], + }; + } + renderSliceTable() { + const mutator = (data) => data.map(slice => ({ + slice: {slice.title}, + favorited: moment.utc(slice.dttm).fromNow(), + _favorited: slice.dttm, + })); + return ( + + ); + } + renderDashboardTable() { + const mutator = (data) => data.map(dash => ({ + dashboard: {dash.title}, + favorited: moment.utc(dash.dttm).fromNow(), + _favorited: dash.dttm, + })); + return ( + + ); + } + render() { + return ( +
+

Dashboards

+ {this.renderDashboardTable()} +
+

Slices

+ {this.renderSliceTable()} +
+ ); + } +} +CreatedContent.propTypes = propTypes; + +export default CreatedContent; diff --git a/superset/assets/javascripts/profile/components/Favorites.jsx b/superset/assets/javascripts/profile/components/Favorites.jsx new file mode 100644 index 0000000000000..7aa31f19fc67a --- /dev/null +++ b/superset/assets/javascripts/profile/components/Favorites.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import moment from 'moment'; +import TableLoader from './TableLoader'; + +const propTypes = { + user: React.PropTypes.object.isRequired, +}; + +export default class Favorites extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + dashboardsLoading: true, + slicesLoading: true, + dashboards: [], + slices: [], + }; + } + renderSliceTable() { + const mutator = (data) => data.map(slice => ({ + slice: {slice.title}, + favorited: moment.utc(slice.dttm).fromNow(), + _favorited: slice.dttm, + })); + return ( + + ); + } + renderDashboardTable() { + const mutator = (data) => data.map(dash => ({ + dashboard: {dash.title}, + favorited: moment.utc(dash.dttm).fromNow(), + })); + return ( + + ); + } + render() { + return ( +
+

Dashboards

+ {this.renderDashboardTable()} +
+

Slices

+ {this.renderSliceTable()} +
+ ); + } +} +Favorites.propTypes = propTypes; diff --git a/superset/assets/javascripts/profile/components/RecentActivity.jsx b/superset/assets/javascripts/profile/components/RecentActivity.jsx new file mode 100644 index 0000000000000..3077e7e6300e6 --- /dev/null +++ b/superset/assets/javascripts/profile/components/RecentActivity.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import TableLoader from './TableLoader'; +import moment from 'moment'; +import $ from 'jquery'; + +const propTypes = { + user: React.PropTypes.object, +}; + +export default class RecentActivity extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + recentActions: [], + }; + } + + componentWillMount() { + $.get(`/superset/recent_activity/${this.props.user.userId}/`, (data) => { + this.setState({ recentActions: data }); + }); + } + render() { + const mutator = function (data) { + return data.map(row => ({ + action: row.action, + item: {row.item_title}, + time: moment.utc(row.time).fromNow(), + _time: row.time, + })); + }; + return ( +
+ +
+ ); + } +} +RecentActivity.propTypes = propTypes; diff --git a/superset/assets/javascripts/profile/components/Security.jsx b/superset/assets/javascripts/profile/components/Security.jsx new file mode 100644 index 0000000000000..2f68d18e00464 --- /dev/null +++ b/superset/assets/javascripts/profile/components/Security.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Badge, Label } from 'react-bootstrap'; + +const propTypes = { + user: React.PropTypes.object.isRequired, +}; +export default function Security({ user }) { + return ( +
+
+

+ Roles {Object.keys(user.roles).length} +

+ {Object.keys(user.roles).map(role => )} +
+
+
+ {user.permissions.database_access && +
+

+ Databases {user.permissions.database_access.length} +

+ {user.permissions.database_access.map(role => )} +
+
+ } +
+
+ {user.permissions.datasource_access && +
+

+ Datasources {user.permissions.datasource_access.length} +

+ {user.permissions.datasource_access.map(role => )} +
+ } +
+
+ ); +} +Security.propTypes = propTypes; diff --git a/superset/assets/javascripts/profile/components/TableLoader.jsx b/superset/assets/javascripts/profile/components/TableLoader.jsx new file mode 100644 index 0000000000000..b5128589da9a9 --- /dev/null +++ b/superset/assets/javascripts/profile/components/TableLoader.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Table, Tr, Td } from 'reactable'; +import { Collapse } from 'react-bootstrap'; +import $ from 'jquery'; + +const propTypes = { + dataEndpoint: React.PropTypes.string.isRequired, + mutator: React.PropTypes.func, + columns: React.PropTypes.arrayOf(React.PropTypes.string), +}; + +export default class TableLoader extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + isLoading: true, + data: [], + }; + } + componentWillMount() { + $.get(this.props.dataEndpoint, (data) => { + let actualData = data; + if (this.props.mutator) { + actualData = this.props.mutator(data); + } + this.setState({ data: actualData, isLoading: false }); + }); + } + render() { + const tableProps = Object.assign({}, this.props); + let columns = this.props.columns; + if (!columns && this.state.data.length > 0) { + columns = Object.keys(this.state.data[0]).filter(col => col[0] !== '_'); + } + delete tableProps.dataEndpoint; + delete tableProps.mutator; + delete tableProps.columns; + if (this.state.isLoading) { + return loading; + } + return ( + +
+ + {this.state.data.map((row, i) => ( + + {columns.map(col => { + if (row.hasOwnProperty('_' + col)) { + return ( + ); + } + return ; + })} + + ))} +
+ {row[col]} + {row[col]}
+
+
+ ); + } +} +TableLoader.propTypes = propTypes; diff --git a/superset/assets/javascripts/profile/components/UserInfo.jsx b/superset/assets/javascripts/profile/components/UserInfo.jsx new file mode 100644 index 0000000000000..81b9b9d463978 --- /dev/null +++ b/superset/assets/javascripts/profile/components/UserInfo.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Gravatar from 'react-gravatar'; +import moment from 'moment'; +import { Panel } from 'react-bootstrap'; + +const propTypes = { + user: React.PropTypes.object.isRequired, +}; +const UserInfo = ({ user }) => ( +
+ + + +
+ +

+ {user.firstName} {user.lastName} +

+

+ {user.username} +

+
+

+ joined {moment(user.createdOn, 'YYYYMMDD').fromNow()} +

+

+ {user.email} +

+

+ {Object.keys(user.roles).join(', ')} +

+

+   + id:  + {user.userId} +

+
+
+); +UserInfo.propTypes = propTypes; +export default UserInfo; diff --git a/superset/assets/javascripts/profile/index.jsx b/superset/assets/javascripts/profile/index.jsx new file mode 100644 index 0000000000000..f32932b182b01 --- /dev/null +++ b/superset/assets/javascripts/profile/index.jsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Badge, Col, Label, Row, Tabs, Tab, Panel } from 'react-bootstrap'; + +import App from './components/App'; + +const $ = window.$ = require('jquery'); +/* eslint no-unused-vars: 0 */ +const jQuery = window.jQuery = $; +require('bootstrap'); +require('./main.css'); + + +const profileViewContainer = document.getElementById('app'); +const bootstrap = JSON.parse(profileViewContainer.getAttribute('data-bootstrap')); + +const user = bootstrap.user; + +ReactDOM.render( + , + profileViewContainer +); diff --git a/superset/assets/javascripts/profile/main.css b/superset/assets/javascripts/profile/main.css new file mode 100644 index 0000000000000..a50e6787cc600 --- /dev/null +++ b/superset/assets/javascripts/profile/main.css @@ -0,0 +1,12 @@ +.tab-pane { + min-height: 400px; + background: white; + border: 1px solid #bbb; + border-top: 0px; +} + +.label { + display: inline-block; + margin-right: 5px; + margin-bottom: 5px; +} diff --git a/superset/assets/package.json b/superset/assets/package.json index dc646fdf4faea..a57b60ef242b7 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -69,6 +69,7 @@ "react-bootstrap-table": "^2.3.8", "react-dom": "^15.3.2", "react-draggable": "^2.1.2", + "react-gravatar": "^2.6.1", "react-grid-layout": "^0.13.1", "react-map-gl": "^1.7.0", "react-redux": "^4.4.5", diff --git a/superset/assets/spec/javascripts/profile/App_spec.jsx b/superset/assets/spec/javascripts/profile/App_spec.jsx new file mode 100644 index 0000000000000..d85add18b0243 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/App_spec.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import App from '../../../javascripts/profile/components/App'; +import { Col, Row, Tab } from 'react-bootstrap'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user } from './fixtures'; + +describe('App', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders 2 Col', () => { + const wrapper = mount(); + expect(wrapper.find(Row)).to.have.length(1); + expect(wrapper.find(Col)).to.have.length(2); + }); + it('renders 4 Tabs', () => { + const wrapper = mount(); + expect(wrapper.find(Tab)).to.have.length(4); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx new file mode 100644 index 0000000000000..d15e00b16801e --- /dev/null +++ b/superset/assets/spec/javascripts/profile/CreatedContent_spec.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import CreatedContent from '../../../javascripts/profile/components/CreatedContent'; +import TableLoader from '../../../javascripts/profile/components/TableLoader'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user } from './fixtures'; + + +describe('CreatedContent', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders 2 TableLoader', () => { + const wrapper = mount(); + expect(wrapper.find(TableLoader)).to.have.length(2); + }); + it('renders 2 titles', () => { + const wrapper = mount(); + expect(wrapper.find('h3')).to.have.length(2); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/Favorites_spec.jsx b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx new file mode 100644 index 0000000000000..4a7908c1dd902 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/Favorites_spec.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Favorites from '../../../javascripts/profile/components/Favorites'; +import TableLoader from '../../../javascripts/profile/components/TableLoader'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user } from './fixtures'; + +describe('Favorites', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders 2 TableLoader', () => { + const wrapper = mount(); + expect(wrapper.find(TableLoader)).to.have.length(2); + }); + it('renders 2 titles', () => { + const wrapper = mount(); + expect(wrapper.find('h3')).to.have.length(2); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx new file mode 100644 index 0000000000000..8c293fb2371e1 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/RecentActivity_spec.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import RecentActivity from '../../../javascripts/profile/components/RecentActivity'; +import TableLoader from '../../../javascripts/profile/components/TableLoader'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user } from './fixtures'; + + +describe('RecentActivity', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders a TableLoader', () => { + const wrapper = mount(); + expect(wrapper.find(TableLoader)).to.have.length(1); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/Security_spec.jsx b/superset/assets/spec/javascripts/profile/Security_spec.jsx new file mode 100644 index 0000000000000..acce932404386 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/Security_spec.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Security from '../../../javascripts/profile/components/Security'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user, userNoPerms } from './fixtures'; + + +describe('Security', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders 2 role labels', () => { + const wrapper = mount(); + expect(wrapper.find('.roles').find('.label')).to.have.length(2); + }); + it('renders 2 datasource labels', () => { + const wrapper = mount(); + expect(wrapper.find('.datasources').find('.label')).to.have.length(2); + }); + it('renders 3 database labels', () => { + const wrapper = mount(); + expect(wrapper.find('.databases').find('.label')).to.have.length(3); + }); + it('renders no permission label when empty', () => { + const wrapper = mount(); + expect(wrapper.find('.datasources').find('.label')).to.have.length(0); + expect(wrapper.find('.databases').find('.label')).to.have.length(0); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/UserInfo_spec.jsx b/superset/assets/spec/javascripts/profile/UserInfo_spec.jsx new file mode 100644 index 0000000000000..c775965fd39a8 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/UserInfo_spec.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import UserInfo from '../../../javascripts/profile/components/UserInfo'; +import Gravatar from 'react-gravatar'; +import { Panel } from 'react-bootstrap'; +import { mount } from 'enzyme'; +import { describe, it } from 'mocha'; +import { expect } from 'chai'; +import { user } from './fixtures'; + + +describe('UserInfo', () => { + const mockedProps = { + user, + }; + it('is valid', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + it('renders a Gravatar', () => { + const wrapper = mount(); + expect(wrapper.find(Gravatar)).to.have.length(1); + }); + it('renders a Panel', () => { + const wrapper = mount(); + expect(wrapper.find(Panel)).to.have.length(1); + }); + it('renders 5 icons', () => { + const wrapper = mount(); + expect(wrapper.find('i')).to.have.length(5); + }); + it('renders roles information', () => { + const wrapper = mount(); + expect(wrapper.find('.roles').text()).to.equal(' Alpha, sql_lab'); + }); + it('shows the right user-id', () => { + const wrapper = mount(); + expect(wrapper.find('.user-id').text()).to.equal('5'); + }); +}); diff --git a/superset/assets/spec/javascripts/profile/fixtures.jsx b/superset/assets/spec/javascripts/profile/fixtures.jsx new file mode 100644 index 0000000000000..13237d1d091a2 --- /dev/null +++ b/superset/assets/spec/javascripts/profile/fixtures.jsx @@ -0,0 +1,52 @@ +export const user = { + username: 'alpha', + roles: { + Alpha: [ + [ + 'can_this_form_post', + 'ResetMyPasswordView', + ], + [ + 'can_this_form_get', + 'ResetMyPasswordView', + ], + [ + 'can_this_form_post', + 'UserInfoEditView', + ], + [ + 'can_this_form_get', + 'UserInfoEditView', + ], + ], + sql_lab: [ + [ + 'menu_access', + 'SQL Lab', + ], + [ + 'can_sql_json', + 'Superset', + ], + [ + 'can_search_queries', + 'Superset', + ], + [ + 'can_csv', + 'Superset', + ], + ], + }, + firstName: 'alpha', + lastName: 'alpha', + createdOn: '2016-11-11T12:34:17', + userId: 5, + email: 'alpha@alpha.com', + isActive: true, + permissions: { + datasource_access: ['table1', 'table2'], + database_access: ['db1', 'db2', 'db3'], + }, +}; +export const userNoPerms = Object.assign({}, user, { permissions: {} }); diff --git a/superset/assets/stylesheets/profile.css b/superset/assets/stylesheets/profile.css new file mode 100644 index 0000000000000..d46e2522b6207 --- /dev/null +++ b/superset/assets/stylesheets/profile.css @@ -0,0 +1,3 @@ +.table i { + padding-top: 6px; +} diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index 8d554cc78d983..128840cfb2e45 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -20,6 +20,7 @@ const config = { sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'], standalone: ['babel-polyfill', APP_DIR + '/javascripts/standalone.js'], welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome.js'], + profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'], }, output: { path: BUILD_DIR, diff --git a/superset/models.py b/superset/models.py index 8407bd1a4d0eb..d60bca49e58f1 100644 --- a/superset/models.py +++ b/superset/models.py @@ -41,7 +41,7 @@ from sqlalchemy import ( Column, Integer, String, ForeignKey, Text, Boolean, DateTime, Date, Table, Numeric, - create_engine, MetaData, desc, asc, select, and_, func + create_engine, MetaData, desc, asc, select, and_ ) from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.declarative import declared_attr @@ -2223,7 +2223,7 @@ class Log(Model): slice_id = Column(Integer) json = Column(Text) user = relationship('User', backref='logs', foreign_keys=[user_id]) - dttm = Column(DateTime, default=func.now()) + dttm = Column(DateTime, default=datetime.utcnow) dt = Column(Date, default=date.today()) @classmethod @@ -2444,7 +2444,7 @@ class FavStar(Model): user_id = Column(Integer, ForeignKey('ab_user.id')) class_name = Column(String(50)) obj_id = Column(Integer) - dttm = Column(DateTime, default=func.now()) + dttm = Column(DateTime, default=datetime.utcnow) class QueryStatus: diff --git a/superset/source_registry.py b/superset/source_registry.py index 669ca176fe243..0705460c646cd 100644 --- a/superset/source_registry.py +++ b/superset/source_registry.py @@ -9,6 +9,7 @@ class SourceRegistry(object): @classmethod def register_sources(cls, datasource_config): for module_name, class_names in datasource_config.items(): + class_names = [str(s) for s in class_names] module_obj = __import__(module_name, fromlist=class_names) for class_name in class_names: source_class = getattr(module_obj, class_name) diff --git a/superset/templates/appbuilder/navbar.html b/superset/templates/appbuilder/navbar.html index 1d6f6e7e3aacf..2e6c2b9272cea 100644 --- a/superset/templates/appbuilder/navbar.html +++ b/superset/templates/appbuilder/navbar.html @@ -2,14 +2,14 @@ {% set languages = appbuilder.languages %}