Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding a stats footer to the dashboard view #2704

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions UPDATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
This document details changes that aren't fully backwards compatible.
This doc is here to inform of change of behavior as well as
to explain actions that may be required to take while upgrading.

# 0.19.0
* We introduced `superset.security_manager.SupersetSecurityManager`,
that derives `flask_appbuilder.security.sqla.manager.SecurityManager`.
This derivation of FAB's SecurityManager was necessary in order to
introduce new attributes to `SupersetUser` like the `image_url` surfaced
in the profile page as well as in the new dashboard stats footer.

Knowing that the authentication in FAB is implemented by deriving their
`SecurityManager`, if you have your own auth setup in that way, you'll now
have to derive `SupersetSecurityManager` instead.


9 changes: 9 additions & 0 deletions docs/security.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ Security in Superset is handled by Flask AppBuilder (FAB). FAB is a
"Simple and rapid application development framework, built on top of Flask.".
FAB provides authentication, user management, permissions and roles.

FAB supports multiple types of authentications:
in-database, LDAP, OpenID, OAuth, REMOTE_USER, or custom.

To setup/customize authentication for Superset, follow
`FAB's documentation<https://flask-appbuilder.readthedocs.io/en/latest/security.html#security>`_, but instead of deriving ``flask_appbuilder.security.sqla.manager.SecurityManager``,
derive Superset's ``superset.security_manager.SupersetSecurityManager``.

Note that Superset extends FAB's ``SecurityManger`` to extend
the user model as specified in the FAB documentation.

Provided Roles
--------------
Expand Down
48 changes: 28 additions & 20 deletions superset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from superset.connectors.connector_registry import ConnectorRegistry
from superset import utils, config # noqa
from superset.security_manager import SupersetSecurityManager


APP_DIR = os.path.dirname(__file__)
Expand Down Expand Up @@ -77,25 +78,25 @@ def get_js_manifest():
migrate = Migrate(app, db, directory=APP_DIR + "/migrations")

# Logging configuration
logging.basicConfig(format=app.config.get('LOG_FORMAT'))
logging.getLogger().setLevel(app.config.get('LOG_LEVEL'))

if app.config.get('ENABLE_TIME_ROTATE'):
logging.getLogger().setLevel(app.config.get('TIME_ROTATE_LOG_LEVEL'))
handler = TimedRotatingFileHandler(app.config.get('FILENAME'),
when=app.config.get('ROLLOVER'),
interval=app.config.get('INTERVAL'),
backupCount=app.config.get('BACKUP_COUNT'))
logging.basicConfig(format=conf.get('LOG_FORMAT'))
logging.getLogger().setLevel(conf.get('LOG_LEVEL'))

if conf.get('ENABLE_TIME_ROTATE'):
logging.getLogger().setLevel(conf.get('TIME_ROTATE_LOG_LEVEL'))
handler = TimedRotatingFileHandler(conf.get('FILENAME'),
when=conf.get('ROLLOVER'),
interval=conf.get('INTERVAL'),
backupCount=conf.get('BACKUP_COUNT'))
logging.getLogger().addHandler(handler)

if app.config.get('ENABLE_CORS'):
if conf.get('ENABLE_CORS'):
from flask_cors import CORS
CORS(app, **app.config.get('CORS_OPTIONS'))
CORS(app, **conf.get('CORS_OPTIONS'))

if app.config.get('ENABLE_PROXY_FIX'):
if conf.get('ENABLE_PROXY_FIX'):
app.wsgi_app = ProxyFix(app.wsgi_app)

if app.config.get('ENABLE_CHUNK_ENCODING'):
if conf.get('ENABLE_CHUNK_ENCODING'):
class ChunkedEncodingFix(object):

def __init__(self, app):
Expand All @@ -109,13 +110,13 @@ def __call__(self, environ, start_response):
return self.app(environ, start_response)
app.wsgi_app = ChunkedEncodingFix(app.wsgi_app)

if app.config.get('UPLOAD_FOLDER'):
if conf.get('UPLOAD_FOLDER'):
try:
os.makedirs(app.config.get('UPLOAD_FOLDER'))
os.makedirs(conf.get('UPLOAD_FOLDER'))
except OSError:
pass

for middleware in app.config.get('ADDITIONAL_MIDDLEWARE'):
for middleware in conf.get('ADDITIONAL_MIDDLEWARE'):
app.wsgi_app = middleware(app.wsgi_app)


Expand All @@ -124,20 +125,27 @@ class MyIndexView(IndexView):
def index(self):
return redirect('/superset/welcome')

SecurityManager = (
conf.get("CUSTOM_SECURITY_MANAGER") or SupersetSecurityManager)
if not issubclass(SecurityManager, SupersetSecurityManager):
raise Exception(
"Security manager needs to be a subclass of "
"superset.security.SupersetSecurityManager")

appbuilder = AppBuilder(
app, db.session,
base_template='superset/base.html',
indexview=MyIndexView,
security_manager_class=app.config.get("CUSTOM_SECURITY_MANAGER"))
security_manager_class=SecurityManager)

sm = appbuilder.sm

get_session = appbuilder.get_session
results_backend = app.config.get("RESULTS_BACKEND")
results_backend = conf.get("RESULTS_BACKEND")

# Registering sources
module_datasource_map = app.config.get("DEFAULT_MODULE_DS_MAP")
module_datasource_map.update(app.config.get("ADDITIONAL_MODULE_DS_MAP"))
module_datasource_map = conf.get("DEFAULT_MODULE_DS_MAP")
module_datasource_map.update(conf.get("ADDITIONAL_MODULE_DS_MAP"))
ConnectorRegistry.register_sources(module_datasource_map)

from superset import views # noqa
21 changes: 12 additions & 9 deletions superset/assets/javascripts/components/TooltipWrapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { slugify } from '../modules/utils';

const propTypes = {
label: PropTypes.string.isRequired,
tooltip: PropTypes.node.isRequired,
tooltip: PropTypes.node,
children: PropTypes.node.isRequired,
placement: PropTypes.string,
};
Expand All @@ -15,14 +15,17 @@ const defaultProps = {
};

export default function TooltipWrapper({ label, tooltip, children, placement }) {
return (
<OverlayTrigger
placement={placement}
overlay={<Tooltip id={`${slugify(label)}-tooltip`}>{tooltip}</Tooltip>}
>
{children}
</OverlayTrigger>
);
if (tooltip) {
return (
<OverlayTrigger
placement={placement}
overlay={<Tooltip id={`${slugify(label)}-tooltip`}>{tooltip}</Tooltip>}
>
{children}
</OverlayTrigger>
);
}
return children;
}

TooltipWrapper.propTypes = propTypes;
Expand Down
61 changes: 61 additions & 0 deletions superset/assets/javascripts/components/UserImage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';
import PropTypes from 'prop-types';
import Gravatar from 'react-gravatar';

import TooltipWrapper from './TooltipWrapper';

const propTypes = {
href: PropTypes.string,
user: PropTypes.object,
tooltip: PropTypes.node,
width: PropTypes.string,
height: PropTypes.string,
imgStyle: PropTypes.object,
showTooltip: PropTypes.bool,
linkToProfile: PropTypes.bool,
};
const defaultProps = {
imgStyle: { borderRadius: 3 },
linkToProfile: true,
showTooltip: true,
};

export default function UserImage(props) {
let tooltip;
if (props.showTooltip) {
tooltip = props.tooltip || props.user.username;
}
let href = props.href || '#';
if (props.linkToProfile) {
href = `/superset/profile/${props.user.username}/`;
}
return (
<TooltipWrapper label={`user-${props.user.username}`} tooltip={tooltip}>
<span className="user-icon m-l-3">
<a href={href}>
{props.user.image_url ?
<img
alt={tooltip || 'User image'}
src={props.user.image_url}
width={props.width}
height={props.height}
style={props.imgStyle}
/>
:
<Gravatar
email={props.user.email}
width={props.width}
height={props.height}
alt="Profile picture provided by Gravatar"
className="img-rounded"
style={props.imgStyle}
/>
}
</a>
</span>
</TooltipWrapper>
);
}

UserImage.propTypes = propTypes;
UserImage.defaultProps = defaultProps;
7 changes: 7 additions & 0 deletions superset/assets/javascripts/dashboard/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import moment from 'moment';

import GridLayout from './components/GridLayout';
import Header from './components/Header';
import Footer from './components/Footer';
import { appSetup } from '../common';
import AlertsWrapper from '../components/AlertsWrapper';

Expand Down Expand Up @@ -72,6 +73,12 @@ function initDashboardView(dashboard) {
<GridLayout dashboard={dashboard} />,
document.getElementById('grid-container'),
);
if (dashboard.ENABLE_DASHBOARD_STATS) {
render(
<Footer dashboard={dashboard} />,
document.getElementById('dash-footer'),
);
}

// Displaying widget controls on hover
$('.react-grid-item').hover(
Expand Down
21 changes: 21 additions & 0 deletions superset/assets/javascripts/dashboard/components/Footer.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.dash-footer {
background-color: white;
margin-top: 10px;
}
.m-l-3 {
margin-left: 3;
}
.user-icon {
opacity: 0.8;
}
.user-icon:hover {
opacity: 1;
}
.navbar.bottom {
margin-bottom: 10px;
border-color: #DDD;
border-width: 1px;
}
.navbar .profile-images img {
margin-top: 8px;
}
127 changes: 127 additions & 0 deletions superset/assets/javascripts/dashboard/components/Footer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React from 'react';
import { Badge, Label, Navbar } from 'react-bootstrap';
import $ from 'jquery';

import './Footer.css';
import UserImage from '../../components/UserImage';
import TooltipWrapper from '../../components/TooltipWrapper';


const propTypes = {
dashboard: React.PropTypes.object,
};
const defaultProps = {
};

const PROFILE_IMG_SIZE = '32';
const MAX_VIEWERS = 30;

class Footer extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
totalViews: '?',
userCount: '?',
viewers: [],
};
}
componentWillMount() {
$.ajax({
url: `/superset/dashboard/${this.props.dashboard.id}/stats/`,
success: (resp) => {
const viewers = resp;
let totalViews = 0;
resp.forEach((d) => {
totalViews += d.views;
});
const userCount = resp.length;
this.setState({ totalViews, userCount, viewers });
},
error() {
},
});
}
renderViewers() {
const viewers = this.state.viewers.slice(0, MAX_VIEWERS);
let extra;
if (this.state.viewers.length > MAX_VIEWERS) {
extra = (
<Label className="m-l-3">
<i className="fa fa-plus-circle" /> {''}
{this.state.viewers.length - MAX_VIEWERS} more
</Label>);
}
return (
<span>
{viewers.map(user => this.renderUserIcon(user))}
{extra}
</span>);
}
renderUserIcon(user) {
return (
<UserImage
user={user}
tooltip={
<div>
<div>{user.first_name} {user.last_name}</div>
{user.views &&
<div><b>{user.views}</b> views</div>
}
</div>
}
width={PROFILE_IMG_SIZE}
height={PROFILE_IMG_SIZE}
/>
);
}
render() {
const dashboard = this.props.dashboard;
return (
<Navbar fluid inverse className="bottom">
<Navbar.Collapse>
<span>
<Navbar.Text>
<TooltipWrapper label="owners" tooltip="Owner(s)">
<i className="fa fa-wrench" />
</TooltipWrapper>
</Navbar.Text>
<span
className="profile-images pull-left"
style={{ marginRight: 50 }}
>
{dashboard.owners.map(user => this.renderUserIcon(user))}
</span>
</span>
<span>
<Navbar.Text>
<TooltipWrapper label="viewers" tooltip="Viewers">
<i className="fa fa-users" />
</TooltipWrapper>
</Navbar.Text>
<span className="profile-images pull-left">
{this.renderViewers()}
</span>
</span>
<Navbar.Text pullRight>
<TooltipWrapper label="user-views" tooltip="Number of views">
<span>
<i className="fa fa-eye" /> <Badge>{this.state.totalViews}</Badge>
</span>
</TooltipWrapper>
</Navbar.Text>
<Navbar.Text pullRight>
<TooltipWrapper label="user-count" tooltip="Number of viewers">
<span>
<i className="fa fa-user" /> <Badge>{this.state.userCount}</Badge>
</span>
</TooltipWrapper>
</Navbar.Text>
</Navbar.Collapse>
</Navbar>
);
}
}
Footer.propTypes = propTypes;
Footer.defaultProps = defaultProps;

export default Footer;
Loading