diff --git a/superset/assets/javascripts/dashboard/components/Header.jsx b/superset/assets/javascripts/dashboard/components/Header.jsx
index bfedb4fd1..3f94fa3eb 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 000000000..c12d09269
--- /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 000000000..d779e1989
--- /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 000000000..7aa31f19f
--- /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 000000000..3077e7e63
--- /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 000000000..2f68d18e0
--- /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 000000000..b5128589d
--- /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
;
+ }
+ return (
+
+
+
+ {this.state.data.map((row, i) => (
+
+ {columns.map(col => {
+ if (row.hasOwnProperty('_' + col)) {
+ return (
+ |
+ {row[col]}
+ | );
+ }
+ return {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 000000000..81b9b9d46
--- /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 000000000..f32932b18
--- /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 000000000..a50e6787c
--- /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 dc646fdf4..a57b60ef2 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 000000000..d85add18b
--- /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 000000000..d15e00b16
--- /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 000000000..4a7908c1d
--- /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 000000000..8c293fb23
--- /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 000000000..acce93240
--- /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 000000000..c775965fd
--- /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 000000000..13237d1d0
--- /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 000000000..d46e2522b
--- /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 8d554cc78..128840cfb 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 8407bd1a4..d60bca49e 100644
--- a/superset/models.py
+++ b/superset/models.py
@@ -41,7 +41,7 @@ from six import string_types
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 669ca176f..0705460c6 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 1d6f6e7e3..2e6c2b927 100644
--- a/superset/templates/appbuilder/navbar.html
+++ b/superset/templates/appbuilder/navbar.html
@@ -2,14 +2,14 @@
{% set languages = appbuilder.languages %}