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
This commit is contained in:
parent
5ae98bc7c9
commit
7e1852ee88
|
|
@ -19,7 +19,10 @@ class Header extends React.PureComponent {
|
|||
return (
|
||||
<div className="title">
|
||||
<div className="pull-left">
|
||||
<h1>{dashboard.dashboard_title}</h1>
|
||||
<h1>
|
||||
{dashboard.dashboard_title}
|
||||
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
|
||||
</h1>
|
||||
</div>
|
||||
<div className="pull-right">
|
||||
{!this.props.dashboard.context.standalone_mode &&
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="container app">
|
||||
<Row>
|
||||
<Col md={3}>
|
||||
<UserInfo user={props.user} />
|
||||
</Col>
|
||||
<Col md={9}>
|
||||
<Tabs id="options">
|
||||
<Tab eventKey={1} title={<div><i className="fa fa-star" /> Favorites</div>}>
|
||||
<Panel><Favorites user={props.user} /></Panel>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={2}
|
||||
title={
|
||||
<div><i className="fa fa-paint-brush" /> Created Content</div>
|
||||
}
|
||||
>
|
||||
<Panel>
|
||||
<CreatedContent user={props.user} />
|
||||
</Panel>
|
||||
</Tab>
|
||||
<Tab eventKey={3} title={<div><i className="fa fa-list" /> Recent Activity</div>}>
|
||||
<Panel>
|
||||
<RecentActivity user={props.user} />
|
||||
</Panel>
|
||||
</Tab>
|
||||
<Tab eventKey={4} title={<div><i className="fa fa-lock" /> Security & Access</div>}>
|
||||
<Panel>
|
||||
<Security user={props.user} />
|
||||
</Panel>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
App.propTypes = propTypes;
|
||||
|
|
@ -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: <a href={slice.url}>{slice.title}</a>,
|
||||
favorited: moment.utc(slice.dttm).fromNow(),
|
||||
_favorited: slice.dttm,
|
||||
}));
|
||||
return (
|
||||
<TableLoader
|
||||
dataEndpoint={`/superset/created_slices/${this.props.user.userId}/`}
|
||||
className="table table-condensed"
|
||||
columns={['slice', 'favorited']}
|
||||
mutator={mutator}
|
||||
noDataText="No slices"
|
||||
sortable
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderDashboardTable() {
|
||||
const mutator = (data) => data.map(dash => ({
|
||||
dashboard: <a href={dash.url}>{dash.title}</a>,
|
||||
favorited: moment.utc(dash.dttm).fromNow(),
|
||||
_favorited: dash.dttm,
|
||||
}));
|
||||
return (
|
||||
<TableLoader
|
||||
className="table table-condensed"
|
||||
mutator={mutator}
|
||||
dataEndpoint={`/superset/created_dashboards/${this.props.user.userId}/`}
|
||||
noDataText="No dashboards"
|
||||
columns={['dashboard', 'favorited']}
|
||||
sortable
|
||||
/>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h3>Dashboards</h3>
|
||||
{this.renderDashboardTable()}
|
||||
<hr />
|
||||
<h3>Slices</h3>
|
||||
{this.renderSliceTable()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
CreatedContent.propTypes = propTypes;
|
||||
|
||||
export default CreatedContent;
|
||||
|
|
@ -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: <a href={slice.url}>{slice.title}</a>,
|
||||
favorited: moment.utc(slice.dttm).fromNow(),
|
||||
_favorited: slice.dttm,
|
||||
}));
|
||||
return (
|
||||
<TableLoader
|
||||
dataEndpoint={`/superset/fave_slices/${this.props.user.userId}/`}
|
||||
className="table table-condensed"
|
||||
columns={['slice', 'favorited']}
|
||||
mutator={mutator}
|
||||
noDataText="No favorite slices yet, go click on stars!"
|
||||
sortable
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderDashboardTable() {
|
||||
const mutator = (data) => data.map(dash => ({
|
||||
dashboard: <a href={dash.url}>{dash.title}</a>,
|
||||
favorited: moment.utc(dash.dttm).fromNow(),
|
||||
}));
|
||||
return (
|
||||
<TableLoader
|
||||
className="table table-condensed"
|
||||
mutator={mutator}
|
||||
dataEndpoint={`/superset/fave_dashboards/${this.props.user.userId}/`}
|
||||
noDataText="No favorite dashboards yet, go click on stars!"
|
||||
columns={['dashboard', 'favorited']}
|
||||
sortable
|
||||
/>
|
||||
);
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<h3>Dashboards</h3>
|
||||
{this.renderDashboardTable()}
|
||||
<hr />
|
||||
<h3>Slices</h3>
|
||||
{this.renderSliceTable()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Favorites.propTypes = propTypes;
|
||||
|
|
@ -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: <a href={row.item_url}>{row.item_title}</a>,
|
||||
time: moment.utc(row.time).fromNow(),
|
||||
_time: row.time,
|
||||
}));
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<TableLoader
|
||||
className="table table-condensed"
|
||||
mutator={mutator}
|
||||
sortable
|
||||
dataEndpoint={`/superset/recent_activity/${this.props.user.userId}/`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
RecentActivity.propTypes = propTypes;
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<div className="roles">
|
||||
<h4>
|
||||
Roles <Badge>{Object.keys(user.roles).length}</Badge>
|
||||
</h4>
|
||||
{Object.keys(user.roles).map(role => <Label key={role}>{role}</Label>)}
|
||||
<hr />
|
||||
</div>
|
||||
<div className="databases">
|
||||
{user.permissions.database_access &&
|
||||
<div>
|
||||
<h4>
|
||||
Databases <Badge>{user.permissions.database_access.length}</Badge>
|
||||
</h4>
|
||||
{user.permissions.database_access.map(role => <Label key={role}>{role}</Label>)}
|
||||
<hr />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="datasources">
|
||||
{user.permissions.datasource_access &&
|
||||
<div>
|
||||
<h4>
|
||||
Datasources <Badge>{user.permissions.datasource_access.length}</Badge>
|
||||
</h4>
|
||||
{user.permissions.datasource_access.map(role => <Label key={role}>{role}</Label>)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Security.propTypes = propTypes;
|
||||
|
|
@ -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 <img alt="loading" width="25" src="/static/assets/images/loading.gif" />;
|
||||
}
|
||||
return (
|
||||
<Collapse in transitionAppear >
|
||||
<div>
|
||||
<Table {...tableProps}>
|
||||
{this.state.data.map((row, i) => (
|
||||
<Tr key={i}>
|
||||
{columns.map(col => {
|
||||
if (row.hasOwnProperty('_' + col)) {
|
||||
return (
|
||||
<Td key={col} column={col} value={row['_' + col]}>
|
||||
{row[col]}
|
||||
</Td>);
|
||||
}
|
||||
return <Td key={col} column={col}>{row[col]}</Td>;
|
||||
})}
|
||||
</Tr>
|
||||
))}
|
||||
</Table>
|
||||
</div>
|
||||
</Collapse>
|
||||
);
|
||||
}
|
||||
}
|
||||
TableLoader.propTypes = propTypes;
|
||||
|
|
@ -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 }) => (
|
||||
<div>
|
||||
<a href="https://en.gravatar.com/">
|
||||
<Gravatar
|
||||
email={user.email}
|
||||
width="100%"
|
||||
height=""
|
||||
alt="Profile picture provided by Gravatar"
|
||||
className="img-rounded"
|
||||
style={{ borderRadius: 15 }}
|
||||
/>
|
||||
</a>
|
||||
<hr />
|
||||
<Panel>
|
||||
<h3>
|
||||
<strong>{user.firstName} {user.lastName}</strong>
|
||||
</h3>
|
||||
<h4 className="username">
|
||||
<i className="fa fa-user-o" /> {user.username}
|
||||
</h4>
|
||||
<hr />
|
||||
<p>
|
||||
<i className="fa fa-clock-o" /> joined {moment(user.createdOn, 'YYYYMMDD').fromNow()}
|
||||
</p>
|
||||
<p className="email">
|
||||
<i className="fa fa-envelope-o" /> {user.email}
|
||||
</p>
|
||||
<p className="roles">
|
||||
<i className="fa fa-lock" /> {Object.keys(user.roles).join(', ')}
|
||||
</p>
|
||||
<p>
|
||||
<i className="fa fa-key" />
|
||||
<span className="text-muted">id:</span>
|
||||
<span className="user-id">{user.userId}</span>
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
UserInfo.propTypes = propTypes;
|
||||
export default UserInfo;
|
||||
|
|
@ -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(
|
||||
<App user={user} />,
|
||||
profileViewContainer
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(<App {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders 2 Col', () => {
|
||||
const wrapper = mount(<App {...mockedProps} />);
|
||||
expect(wrapper.find(Row)).to.have.length(1);
|
||||
expect(wrapper.find(Col)).to.have.length(2);
|
||||
});
|
||||
it('renders 4 Tabs', () => {
|
||||
const wrapper = mount(<App {...mockedProps} />);
|
||||
expect(wrapper.find(Tab)).to.have.length(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<CreatedContent {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders 2 TableLoader', () => {
|
||||
const wrapper = mount(<CreatedContent {...mockedProps} />);
|
||||
expect(wrapper.find(TableLoader)).to.have.length(2);
|
||||
});
|
||||
it('renders 2 titles', () => {
|
||||
const wrapper = mount(<CreatedContent {...mockedProps} />);
|
||||
expect(wrapper.find('h3')).to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<Favorites {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders 2 TableLoader', () => {
|
||||
const wrapper = mount(<Favorites {...mockedProps} />);
|
||||
expect(wrapper.find(TableLoader)).to.have.length(2);
|
||||
});
|
||||
it('renders 2 titles', () => {
|
||||
const wrapper = mount(<Favorites {...mockedProps} />);
|
||||
expect(wrapper.find('h3')).to.have.length(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<RecentActivity {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders a TableLoader', () => {
|
||||
const wrapper = mount(<RecentActivity {...mockedProps} />);
|
||||
expect(wrapper.find(TableLoader)).to.have.length(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<Security {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders 2 role labels', () => {
|
||||
const wrapper = mount(<Security {...mockedProps} />);
|
||||
expect(wrapper.find('.roles').find('.label')).to.have.length(2);
|
||||
});
|
||||
it('renders 2 datasource labels', () => {
|
||||
const wrapper = mount(<Security {...mockedProps} />);
|
||||
expect(wrapper.find('.datasources').find('.label')).to.have.length(2);
|
||||
});
|
||||
it('renders 3 database labels', () => {
|
||||
const wrapper = mount(<Security {...mockedProps} />);
|
||||
expect(wrapper.find('.databases').find('.label')).to.have.length(3);
|
||||
});
|
||||
it('renders no permission label when empty', () => {
|
||||
const wrapper = mount(<Security user={userNoPerms} />);
|
||||
expect(wrapper.find('.datasources').find('.label')).to.have.length(0);
|
||||
expect(wrapper.find('.databases').find('.label')).to.have.length(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(<UserInfo {...mockedProps} />)
|
||||
).to.equal(true);
|
||||
});
|
||||
it('renders a Gravatar', () => {
|
||||
const wrapper = mount(<UserInfo {...mockedProps} />);
|
||||
expect(wrapper.find(Gravatar)).to.have.length(1);
|
||||
});
|
||||
it('renders a Panel', () => {
|
||||
const wrapper = mount(<UserInfo {...mockedProps} />);
|
||||
expect(wrapper.find(Panel)).to.have.length(1);
|
||||
});
|
||||
it('renders 5 icons', () => {
|
||||
const wrapper = mount(<UserInfo {...mockedProps} />);
|
||||
expect(wrapper.find('i')).to.have.length(5);
|
||||
});
|
||||
it('renders roles information', () => {
|
||||
const wrapper = mount(<UserInfo {...mockedProps} />);
|
||||
expect(wrapper.find('.roles').text()).to.equal(' Alpha, sql_lab');
|
||||
});
|
||||
it('shows the right user-id', () => {
|
||||
const wrapper = mount(<UserInfo {...mockedProps} />);
|
||||
expect(wrapper.find('.user-id').text()).to.equal('5');
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {} });
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.table i {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
{% set languages = appbuilder.languages %}
|
||||
|
||||
<div class="navbar navbar-static-top {{menu.extra_classes}}" role="navigation">
|
||||
<div class="container-fluid">
|
||||
<div class="container{{ '-fluid' if not navbar_container else '' }}">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="{{appbuilder.get_url_for_index}}">
|
||||
<a class="navbar-brand" href="/superset/profile/{{ current_user.username }}/">
|
||||
<img width="100" src="/static/assets/images/superset.png" style="padding-top: 2px;" alt="Superset">
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
<head>
|
||||
<title>
|
||||
{% block title %}
|
||||
{% if appbuilder and appbuilder.app_name %} {{ appbuilder.app_name }} {% endif %}
|
||||
{% if title %}
|
||||
{{ title }}
|
||||
{% elif appbuilder and appbuilder.app_name %}
|
||||
{{ appbuilder.app_name }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</title>
|
||||
{% block head_meta %}{% endblock %}
|
||||
|
|
@ -30,7 +34,7 @@
|
|||
|
||||
{% block body %}
|
||||
{% include 'superset/flash_wrapper.html' %}
|
||||
<div id="app">
|
||||
<div id="app" data-bootstrap="{{ bootstrap_data }}" >
|
||||
<img src="/static/assets/images/loading.gif" style="width: 50px; margin: 10px;">
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<h2>
|
||||
{{ slice.slice_name }}
|
||||
<small class="star-edit-icons">
|
||||
<span class="favstar" obj_id="{{ slice.id }}"></span>
|
||||
<span class="favstar" class_name="Slice" obj_id="{{ slice.id }}"></span>
|
||||
<span>
|
||||
<a
|
||||
href="/slicemodelview/edit/{{ slice.id }}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "superset/basic.html" %}
|
||||
|
||||
{% block tail_js %}
|
||||
{{ super() }}
|
||||
{% with filename="profile" %}
|
||||
{% include "superset/partials/_script_tag.html" %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
|
@ -23,9 +23,11 @@ from flask_appbuilder.actions import action
|
|||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_appbuilder.security.decorators import has_access, has_access_api
|
||||
from flask_appbuilder.widgets import ListWidget
|
||||
from flask_appbuilder.models.sqla.filters import BaseFilter
|
||||
from flask_appbuilder.security.sqla import models as ab_models
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from flask_babel import lazy_gettext as _
|
||||
from flask_appbuilder.models.sqla.filters import BaseFilter
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
|
@ -1700,6 +1702,172 @@ class Superset(BaseSupersetView):
|
|||
status=500,
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/recent_activity/<user_id>/", methods=['GET'])
|
||||
def recent_activity(self, user_id):
|
||||
"""Recent activity (actions) for a given user"""
|
||||
M = models # noqa
|
||||
qry = (
|
||||
db.session.query(M.Log, M.Dashboard, M.Slice)
|
||||
.outerjoin(
|
||||
M.Dashboard,
|
||||
M.Dashboard.id == M.Log.dashboard_id
|
||||
)
|
||||
.outerjoin(
|
||||
M.Slice,
|
||||
M.Slice.id == M.Log.slice_id
|
||||
)
|
||||
.filter(
|
||||
sqla.and_(
|
||||
M.Log.action != 'queries',
|
||||
M.Log.user_id == user_id,
|
||||
)
|
||||
)
|
||||
.order_by(M.Log.dttm.desc())
|
||||
.limit(1000)
|
||||
)
|
||||
payload = []
|
||||
for log in qry.all():
|
||||
item_url = None
|
||||
item_title = None
|
||||
if log.Dashboard:
|
||||
item_url = log.Dashboard.url
|
||||
item_title = log.Dashboard.dashboard_title
|
||||
elif log.Slice:
|
||||
item_url = log.Slice.slice_url
|
||||
item_title = log.Slice.slice_name
|
||||
|
||||
payload.append({
|
||||
'action': log.Log.action,
|
||||
'item_url': item_url,
|
||||
'item_title': item_title,
|
||||
'time': log.Log.dttm,
|
||||
})
|
||||
return Response(
|
||||
json.dumps(payload, default=utils.json_int_dttm_ser),
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/fave_dashboards/<user_id>/", methods=['GET'])
|
||||
def fave_dashboards(self, user_id):
|
||||
qry = (
|
||||
db.session.query(
|
||||
models.Dashboard,
|
||||
models.FavStar.dttm,
|
||||
)
|
||||
.join(
|
||||
models.FavStar,
|
||||
sqla.and_(
|
||||
models.FavStar.user_id == int(user_id),
|
||||
models.FavStar.class_name == 'Dashboard',
|
||||
models.Dashboard.id == models.FavStar.obj_id,
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
models.FavStar.dttm.desc()
|
||||
)
|
||||
)
|
||||
payload = [{
|
||||
'id': o.Dashboard.id,
|
||||
'dashboard': o.Dashboard.dashboard_link(),
|
||||
'title': o.Dashboard.dashboard_title,
|
||||
'url': o.Dashboard.url,
|
||||
'dttm': o.dttm,
|
||||
} for o in qry.all()]
|
||||
return Response(
|
||||
json.dumps(payload, default=utils.json_int_dttm_ser),
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/created_dashboards/<user_id>/", methods=['GET'])
|
||||
def created_dashboards(self, user_id):
|
||||
Dash = models.Dashboard # noqa
|
||||
qry = (
|
||||
db.session.query(
|
||||
Dash,
|
||||
)
|
||||
.filter(
|
||||
sqla.or_(
|
||||
Dash.created_by_fk == user_id,
|
||||
Dash.changed_by_fk == user_id,
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
Dash.changed_on.desc()
|
||||
)
|
||||
)
|
||||
payload = [{
|
||||
'id': o.id,
|
||||
'dashboard': o.dashboard_link(),
|
||||
'title': o.dashboard_title,
|
||||
'url': o.url,
|
||||
'dttm': o.changed_on,
|
||||
} for o in qry.all()]
|
||||
return Response(
|
||||
json.dumps(payload, default=utils.json_int_dttm_ser),
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/created_slices/<user_id>/", methods=['GET'])
|
||||
def created_slices(self, user_id):
|
||||
"""List of slices created by this user"""
|
||||
Slice = models.Slice # noqa
|
||||
qry = (
|
||||
db.session.query(Slice)
|
||||
.filter(
|
||||
sqla.or_(
|
||||
Slice.created_by_fk == user_id,
|
||||
Slice.changed_by_fk == user_id,
|
||||
)
|
||||
)
|
||||
.order_by(Slice.changed_on.desc())
|
||||
)
|
||||
payload = [{
|
||||
'id': o.id,
|
||||
'title': o.slice_name,
|
||||
'url': o.slice_url,
|
||||
'dttm': o.changed_on,
|
||||
} for o in qry.all()]
|
||||
return Response(
|
||||
json.dumps(payload, default=utils.json_int_dttm_ser),
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/fave_slices/<user_id>/", methods=['GET'])
|
||||
def fave_slices(self, user_id):
|
||||
"""Favorite slices for a user"""
|
||||
qry = (
|
||||
db.session.query(
|
||||
models.Slice,
|
||||
models.FavStar.dttm,
|
||||
)
|
||||
.join(
|
||||
models.FavStar,
|
||||
sqla.and_(
|
||||
models.FavStar.user_id == int(user_id),
|
||||
models.FavStar.class_name == 'slice',
|
||||
models.Slice.id == models.FavStar.obj_id,
|
||||
)
|
||||
)
|
||||
.order_by(
|
||||
models.FavStar.dttm.desc()
|
||||
)
|
||||
)
|
||||
payload = [{
|
||||
'id': o.Slice.id,
|
||||
'title': o.Slice.slice_name,
|
||||
'url': o.Slice.slice_url,
|
||||
'dttm': o.dttm,
|
||||
} for o in qry.all()]
|
||||
return Response(
|
||||
json.dumps(payload, default=utils.json_int_dttm_ser),
|
||||
mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/warm_up_cache/", methods=['GET'])
|
||||
|
|
@ -1751,6 +1919,7 @@ class Superset(BaseSupersetView):
|
|||
|
||||
@expose("/favstar/<class_name>/<obj_id>/<action>/")
|
||||
def favstar(self, class_name, obj_id, action):
|
||||
"""Toggle favorite stars on Slices and Dashboard"""
|
||||
session = db.session()
|
||||
FavStar = models.FavStar # noqa
|
||||
count = 0
|
||||
|
|
@ -2225,7 +2394,6 @@ class Superset(BaseSupersetView):
|
|||
|
||||
@has_access
|
||||
@expose("/queries/<last_updated_ms>")
|
||||
@log_this
|
||||
def queries(self, last_updated_ms):
|
||||
"""Get the updated queries."""
|
||||
if not g.user.get_id():
|
||||
|
|
@ -2338,9 +2506,52 @@ class Superset(BaseSupersetView):
|
|||
"""Personalized welcome page"""
|
||||
return self.render_template('superset/welcome.html', utils=utils)
|
||||
|
||||
@has_access
|
||||
@expose("/profile/<username>/")
|
||||
def profile(self, username):
|
||||
"""User profile page"""
|
||||
user = (
|
||||
db.session.query(ab_models.User)
|
||||
.filter_by(username=username)
|
||||
.one()
|
||||
)
|
||||
roles = {}
|
||||
from collections import defaultdict
|
||||
permissions = defaultdict(list)
|
||||
for role in user.roles:
|
||||
perms = []
|
||||
for perm in role.permissions:
|
||||
perms.append(
|
||||
(perm.permission.name, perm.view_menu.name)
|
||||
)
|
||||
if perm.permission.name in ('datasource_access', 'database_access'):
|
||||
permissions[perm.permission.name].append(perm.view_menu.name)
|
||||
roles[role.name] = [
|
||||
[perm.permission.name, perm.view_menu.name]
|
||||
for perm in role.permissions
|
||||
]
|
||||
payload = {
|
||||
'user': {
|
||||
'username': user.username,
|
||||
'firstName': user.first_name,
|
||||
'lastName': user.last_name,
|
||||
'userId': user.id,
|
||||
'isActive': user.is_active(),
|
||||
'createdOn': user.created_on.isoformat(),
|
||||
'email': user.email,
|
||||
'roles': roles,
|
||||
'permissions': permissions,
|
||||
}
|
||||
}
|
||||
return self.render_template(
|
||||
'superset/profile.html',
|
||||
title=user.username + "'s profile",
|
||||
navbar_container=True,
|
||||
bootstrap_data=json.dumps(payload))
|
||||
|
||||
@has_access
|
||||
@expose("/sqllab")
|
||||
def sqlanvil(self):
|
||||
def sqllab(self):
|
||||
"""SQL Editor"""
|
||||
return self.render_template('superset/sqllab.html')
|
||||
|
||||
|
|
|
|||
|
|
@ -440,6 +440,22 @@ class CoreTests(SupersetTestCase):
|
|||
self.assertIn('tables', resp)
|
||||
self.assertIn('views', resp)
|
||||
|
||||
def test_user_profile(self):
|
||||
self.login(username='admin')
|
||||
userid = appbuilder.sm.find_user('admin').id
|
||||
resp = self.get_resp('/superset/profile/admin/')
|
||||
self.assertIn('"app"', resp)
|
||||
data = self.get_json_resp('/superset/recent_activity/{}/'.format(userid))
|
||||
self.assertNotIn('message', data)
|
||||
data = self.get_json_resp('/superset/created_slices/{}/'.format(userid))
|
||||
self.assertNotIn('message', data)
|
||||
data = self.get_json_resp('/superset/created_dashboards/{}/'.format(userid))
|
||||
self.assertNotIn('message', data)
|
||||
data = self.get_json_resp('/superset/fave_slices/{}/'.format(userid))
|
||||
self.assertNotIn('message', data)
|
||||
data = self.get_json_resp('/superset/fave_dashboards/{}/'.format(userid))
|
||||
self.assertNotIn('message', data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in New Issue