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:
Maxime Beauchemin 2016-11-19 21:23:44 -08:00 committed by GitHub
parent 5ae98bc7c9
commit 7e1852ee88
28 changed files with 903 additions and 12 deletions

View File

@ -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} &nbsp;
<span is class="favstar" class_name="Dashboard" obj_id={dashboard.id} />
</h1>
</div>
<div className="pull-right">
{!this.props.dashboard.context.standalone_mode &&

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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" />&nbsp;
<span className="text-muted">id:</span>&nbsp;
<span className="user-id">{user.userId}</span>
</p>
</Panel>
</div>
);
UserInfo.propTypes = propTypes;
export default UserInfo;

View File

@ -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
);

View File

@ -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;
}

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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: {} });

View File

@ -0,0 +1,3 @@
.table i {
padding-top: 6px;
}

View File

@ -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,

View File

@ -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:

View File

@ -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)

View File

@ -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>

View File

@ -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 %}

View File

@ -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 }}"

View File

@ -0,0 +1,8 @@
{% extends "superset/basic.html" %}
{% block tail_js %}
{{ super() }}
{% with filename="profile" %}
{% include "superset/partials/_script_tag.html" %}
{% endwith %}
{% endblock %}

View File

@ -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')

View File

@ -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()