Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Christopher Valles 2014-03-03 11:52:55 +00:00
commit 8130d28442
23 changed files with 606 additions and 128 deletions

View File

@ -55,12 +55,6 @@ It's very likely that in the future we will switch to [D3.js](http://d3js.org/)
Below you can see the "big" features of the next 3 releases (for full list, click on the link):
### [v0.2](https://github.com/EverythingMe/redash/issues?milestone=1&state=open)
- Ability to generate multiple visualizations for a single query (dataset) in a more flexible way than today. Also easier extensbility points to add additional visualizations.
- Support for API access using API keys, instead of Google Login.
- UI Improvements (better notifications & flows, improved queries page)
### [v0.3](https://github.com/EverythingMe/redash/issues?milestone=2&state=open)
- Dashboard filters: ability to filter/slice the data you see in a single dashboard using filters (date or selectors).

View File

@ -8,7 +8,7 @@ machine:
dependencies:
pre:
- make deps
- pip install requests coverage nose
- pip install -r dev_requirements.txt
- pip install -r requirements.txt
cache_directories:
- rd_ui/node_modules/

View File

@ -1,2 +1,3 @@
nose==1.3.0
coverage==3.7.1
mock==1.0.1

View File

@ -10,10 +10,11 @@ atfork.stdlib_fixer.fix_logging_module()
import logging
import time
from redash import settings, app, db, models, data_manager, __version__
from flask.ext.script import Manager
from flask.ext.script import Manager, prompt_pass
manager = Manager(app)
database_manager = Manager(help="Manages the database (create/drop tables).")
users_manager = Manager(help="Users management commands.")
@manager.command
def version():
@ -63,7 +64,34 @@ def drop_tables():
create_db(False, True)
@users_manager.option('email', help="User's email")
@users_manager.option('name', help="User's full name")
@users_manager.option('--admin', dest='is_admin', default=False, help="set user as admin")
@users_manager.option('--google', dest='google_auth', default=False, help="user uses Google Auth to login")
def create(email, name, is_admin=False, google_auth=False):
print "Creating user (%s, %s)..." % (email, name)
print "Admin: %r" % is_admin
print "Login with Google Auth: %r\n" % google_auth
user = models.User(email=email, name=name, is_admin=is_admin)
if not google_auth:
password = prompt_pass("Password")
user.hash_password(password)
try:
user.save()
except Exception, e:
print "Failed creating user: %s" % e.message
@users_manager.option('email', help="email address of user to delete")
def delete(email):
deleted_count = models.User.delete().where(models.User.email == email).execute()
print "Deleted %d users." % deleted_count
manager.add_command("database", database_manager)
manager.add_command("users", users_manager)
if __name__ == '__main__':
channel = logging.StreamHandler()

View File

@ -0,0 +1,12 @@
from playhouse.migrate import Migrator
from redash import db
from redash import models
if __name__ == '__main__':
db.connect_db()
migrator = Migrator(db.database)
with db.database.transaction():
migrator.add_column(models.User, models.User.password_hash, 'password_hash')
db.close_db(None)

View File

@ -0,0 +1,56 @@
import json
import itertools
import peewee
from playhouse.migrate import Migrator
from redash import db, settings
from redash import models
if __name__ == '__main__':
db.connect_db()
if not models.User.table_exists():
print "Creating user table..."
models.User.create_table()
migrator = Migrator(db.database)
with db.database.transaction():
print "Creating user field on dashboard and queries..."
try:
migrator.rename_column(models.Query, '"user"', "user_email")
migrator.rename_column(models.Dashboard, '"user"', "user_email")
except peewee.ProgrammingError:
print "Failed to rename user column -- assuming it already exists"
with db.database.transaction():
models.Query.user.null = True
models.Dashboard.user.null = True
try:
migrator.add_column(models.Query, models.Query.user, "user_id")
migrator.add_column(models.Dashboard, models.Dashboard.user, "user_id")
except peewee.ProgrammingError:
print "Failed to create user_id column -- assuming it already exists"
print "Creating user for all queries and dashboards..."
for obj in itertools.chain(models.Query.select(), models.Dashboard.select()):
# Some old databases might have queries with empty string as user email:
email = obj.user_email or settings.ADMINS[0]
email = email.split(',')[0]
print ".. {} , {}, {}".format(type(obj), obj.id, email)
try:
user = models.User.get(models.User.email == email)
except models.User.DoesNotExist:
is_admin = email in settings.ADMINS
user = models.User.create(email=email, name=email, is_admin=is_admin)
obj.user = user
obj.save()
print "Set user_id to non null..."
with db.database.transaction():
migrator.set_nullable(models.Query, models.Query.user, False)
migrator.set_nullable(models.Dashboard, models.Dashboard.user, False)
migrator.set_nullable(models.Query, models.Query.user_email, True)
migrator.set_nullable(models.Dashboard, models.Dashboard.user_email, True)

View File

@ -170,7 +170,7 @@ module.exports = function (grunt) {
}
},
useminPrepare: {
html: '<%= yeoman.app %>/index.html',
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html'],
options: {
dest: '<%= yeoman.dist %>'
}

View File

@ -124,7 +124,8 @@
var currentUser = {{ user|safe }};
currentUser.canEdit = function(object) {
return object.user && (object.user.indexOf(currentUser.name) != -1);
var user_id = object.user_id || (object.user && object.user.id);
return user_id && (user_id == currentUser.id);
};
{{ analytics|safe }}

85
rd_ui/app/login.html Normal file
View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
<head>
<title>re:dash Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- build:css /styles/main_login.css -->
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">
<link rel="stylesheet" href="/styles/redash.css">
<link rel="stylesheet" href="/styles/login.css">
<!-- endbuild -->
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse"
data-target=".navbar-ex1-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/"><strong>re:dash</strong></a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<div class="main">
<form role="form" method="post" name="login">
<div class="form-group">
<label for="inputUsernameEmail">Username or email</label>
<input type="text" class="form-control" id="inputUsernameEmail" name="username" value="{{username}}">
</div>
<div class="form-group">
<!--<a class="pull-right" href="#">Forgot password?</a>-->
<label for="inputPassword">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password">
</div>
<div class="checkbox pull-right">
<label>
<input type="checkbox" name="remember">
Remember me </label>
</div>
<button type="submit" class="btn btn btn-primary">
Log In
</button>
</form>
{% if show_google_openid %}
<div class="login-or">
<hr class="hr-or">
<span class="span-or">or</span>
</div>
<div class="row">
<div class="col-xs-6 col-sm-6 col-md-6">
<a href="/google_auth/login?next={{next}}" class="btn btn-lg btn-info btn-block">Google</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<script src="/bower_components/jquery/jquery.js"></script>
<script>
{{ analytics|safe }}
</script>
</body>
</html>

View File

@ -239,7 +239,7 @@
$scope.queryResult = $scope.query.getQueryResult();
});
} else {
$scope.query = new Query({query: "", name: "New Query", ttl: -1, user: currentUser.name});
$scope.query = new Query({query: "", name: "New Query", ttl: -1, user: currentUser});
$scope.lockButton(false);
}
@ -303,9 +303,9 @@
}
if ($scope.selectedTab.key == 'my') {
return query.user == currentUser.name && query.name != 'New Query';
return query.user.id == currentUser.id && query.name != 'New Query';
} else if ($scope.selectedTab.key == 'drafts') {
return query.user == currentUser.name && query.name == 'New Query';
return query.user.id == currentUser.id && query.name == 'New Query';
}
return query.name != 'New Query';
@ -330,7 +330,7 @@
},
{
'label': 'Created By',
'map': 'user'
'map': 'user.name'
},
{
'label': 'Created At',

View File

@ -0,0 +1,37 @@
.main {
max-width: 320px;
margin: 0 auto;
}
.login-or {
position: relative;
font-size: 18px;
color: #aaa;
margin-top: 10px;
margin-bottom: 10px;
padding-top: 10px;
padding-bottom: 10px;
}
.span-or {
display: block;
position: absolute;
left: 50%;
top: -2px;
margin-left: -25px;
background-color: #fff;
width: 50px;
text-align: center;
}
.hr-or {
background-color: #cdcdcd;
height: 1px;
margin-top: 0px !important;
margin-bottom: 0px !important;
}
/*h3 {*/
/*text-align: center;*/
/*line-height: 300%;*/
/*}*/

View File

@ -34,7 +34,7 @@
<span ng-show="queryResult.getRuntime()>=0">Query runtime: {{queryResult.getRuntime() | durationHumanize}} | </span>
<span ng-show="queryResult.query_result.retrieved_at">Last update time: <span am-time-ago="queryResult.query_result.retrieved_at"></span> | </span>
<span ng-show="queryResult.getStatus() == 'done'">Rows: {{queryResult.getData().length}} | </span>
Created by: {{query.user}}
Created by: {{query.user.name}}
<div class="pull-right">Refresh query: <select ng-model="query.ttl" ng-options="c.value as c.name for c in refreshOptions"></select><br></div>
</div>
</div>

View File

@ -7,14 +7,13 @@ from flask_peewee.db import Database
import redis
from redash import settings, utils
__version__ = '0.3.2'
__version__ = '0.3.3'
app = Flask(__name__,
template_folder=settings.STATIC_ASSETS_PATH,
static_folder=settings.STATIC_ASSETS_PATH,
static_path='/static')
api = Api(app)
# configure our database
@ -42,4 +41,4 @@ redis_connection = redis.StrictRedis(host=redis_url.hostname, port=redis_url.por
from redash import data
data_manager = data.Manager(redis_connection, db)
from redash import controllers
from redash import controllers

View File

@ -2,11 +2,15 @@ import functools
import hashlib
import hmac
from flask import current_app, request, make_response, g, redirect, url_for
from flask.ext.googleauth import GoogleAuth
from flask.ext.googleauth import GoogleAuth, login
from flask.ext.login import LoginManager, login_user, current_user
import time
import logging
from werkzeug.contrib.fixers import ProxyFix
from redash import models, settings
login_manager = LoginManager()
logger = logging.getLogger('authentication')
def sign(key, path, expires):
if not key:
@ -19,9 +23,6 @@ def sign(key, path, expires):
class HMACAuthentication(object):
def __init__(self, auth):
self.auth = auth
@staticmethod
def api_key_authentication():
signature = request.args.get('signature')
@ -40,40 +41,64 @@ class HMACAuthentication(object):
@staticmethod
def is_user_logged_in():
return g.user is not None
@staticmethod
def valid_user():
email = g.user['email']
if not settings.GOOGLE_APPS_DOMAIN:
return True
return email in settings.ALLOWED_EXTERNAL_USERS or email.endswith("@%s" % settings.GOOGLE_APPS_DOMAIN)
return current_user.is_authenticated()
def required(self, fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
if self.is_user_logged_in() and self.valid_user():
if self.is_user_logged_in():
return fn(*args, **kwargs)
if self.api_key_authentication():
return fn(*args, **kwargs)
blueprint = current_app.extensions['googleauth'].blueprint
# The make_response call is a work around for flask-restful testing only for
# flask.wrappers.Resource instead of werkzeug.wrappers.Response
return make_response(redirect(url_for("%s.login" % blueprint.name, next=request.url)))
return make_response(redirect(url_for("login", next=request.url)))
return decorated
def validate_email(email):
if not settings.GOOGLE_APPS_DOMAIN:
return True
return email in settings.ALLOWED_EXTERNAL_USERS or email.endswith("@%s" % settings.GOOGLE_APPS_DOMAIN)
def create_and_login_user(app, user):
if not validate_email(user.email):
return
try:
user_object = models.User.get(models.User.email == user.email)
if user_object.name != user.name:
logger.debug("Updating user name (%r -> %r)", user_object.name, user.name)
user_object.name = user.name
user_object.save()
except models.User.DoesNotExist:
logger.debug("Creating user object (%r)", user.name)
user_object = models.User.create(name=user.name, email=user.email,
is_admin=(user.email in settings.ADMINS))
login_user(user_object, remember=True)
login.connect(create_and_login_user)
@login_manager.user_loader
def load_user(user_id):
return models.User.select().where(models.User.id == user_id).first()
def setup_authentication(app):
openid_auth = GoogleAuth(app)
# If we don't have a list of external users, we can use Google's federated login, which limits
# the domain with which you can sign in.
if not settings.ALLOWED_EXTERNAL_USERS and settings.GOOGLE_APPS_DOMAIN:
openid_auth._OPENID_ENDPOINT = "https://www.google.com/a/%s/o8/ud?be=o8" % settings.GOOGLE_APPS_DOMAIN
if settings.GOOGLE_OPENID_ENABLED:
openid_auth = GoogleAuth(app, url_prefix="/google_auth")
# If we don't have a list of external users, we can use Google's federated login, which limits
# the domain with which you can sign in.
if not settings.ALLOWED_EXTERNAL_USERS and settings.GOOGLE_APPS_DOMAIN:
openid_auth._OPENID_ENDPOINT = "https://www.google.com/a/%s/o8/ud?be=o8" % settings.GOOGLE_APPS_DOMAIN
login_manager.init_app(app)
app.wsgi_app = ProxyFix(app.wsgi_app)
app.secret_key = settings.COOKIE_SECRET
return HMACAuthentication(openid_auth)
return HMACAuthentication()

View File

@ -11,8 +11,10 @@ import numbers
import cStringIO
import datetime
from flask import g, render_template, send_from_directory, make_response, request, jsonify
from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \
session, url_for
from flask.ext.restful import Resource, abort
from flask_login import current_user, login_user, logout_user
import sqlparse
from redash import settings, utils
@ -34,18 +36,50 @@ def ping():
@app.route('/')
@auth.required
def index(anything=None):
email_md5 = hashlib.md5(g.user['email'].lower()).hexdigest()
email_md5 = hashlib.md5(current_user.email.lower()).hexdigest()
gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5
user = {
'gravatar_url': gravatar_url,
'is_admin': g.user['email'] in settings.ADMINS,
'name': g.user['email']
'is_admin': current_user.is_admin,
'id': current_user.id,
'name': current_user.name,
'email': current_user.email
}
return render_template("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated():
return redirect(request.args.get('next') or '/')
if not settings.PASSWORD_LOGIN_ENABLED:
blueprint = app.extensions['googleauth'].blueprint
return redirect(url_for("%s.login" % blueprint.name, next=request.args.get('next')))
if request.method == 'POST':
user = models.User.select().where(models.User.email == request.form['username']).first()
if user and user.verify_password(request.form['password']):
remember = ('remember' in request.form)
login_user(user, remember=remember)
return redirect(request.args.get('next') or '/')
return render_template("login.html",
analytics=settings.ANALYTICS,
next=request.args.get('next'),
username=request.form.get('username', ''),
show_google_openid=settings.GOOGLE_OPENID_ENABLED)
@app.route('/logout')
def logout():
logout_user()
session.pop('openid', None)
return redirect('/login')
@app.route('/status.json')
@auth.required
def status_api():
@ -80,9 +114,13 @@ def format_sql_query():
class BaseResource(Resource):
decorators = [auth.required]
def __init__(self, *args, **kwargs):
super(BaseResource, self).__init__(*args, **kwargs)
self._user = None
@property
def current_user(self):
return g.user['email']
return current_user._get_current_object()
class DashboardListAPI(BaseResource):
@ -111,9 +149,9 @@ class DashboardAPI(BaseResource):
return dashboard.to_dict(with_widgets=True)
def post(self, dashboard_slug):
# TODO: either convert all requests to use slugs or ids
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard.get(models.Dashboard.id == dashboard_slug)
# TODO: either convert all requests to use slugs or ids
dashboard = models.Dashboard.get_by_id(dashboard_slug)
dashboard.layout = dashboard_properties['layout']
dashboard.name = dashboard_properties['name']
dashboard.save()
@ -198,7 +236,7 @@ class QueryListAPI(BaseResource):
class QueryAPI(BaseResource):
def post(self, query_id):
query_def = request.get_json(force=True)
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data']:
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user']:
query_def.pop(field, None)
if 'latest_query_data_id' in query_def:
@ -326,7 +364,6 @@ class JobAPI(BaseResource):
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')
@app.route('/<path:filename>')
@auth.required
def send_static(filename):
return send_from_directory(settings.STATIC_ASSETS_PATH, filename)

View File

@ -3,25 +3,46 @@ import hashlib
import time
import datetime
from flask.ext.peewee.utils import slugify
from flask.ext.login import UserMixin
from passlib.apps import custom_app_context as pwd_context
import peewee
from redash import db, utils
#class User(db.Model):
# id = db.Column(db.Integer, primary_key=True)
# name = db.Column(db.String(320))
# email = db.Column(db.String(160), unique=True)
#
# def __repr__(self):
# return '<User %r, %r>' % (self.name, self.email)
class BaseModel(db.Model):
@classmethod
def get_by_id(cls, model_id):
return cls.get(cls.id == model_id)
class User(BaseModel, UserMixin):
id = peewee.PrimaryKeyField()
name = peewee.CharField(max_length=320)
email = peewee.CharField(max_length=320, index=True, unique=True)
password_hash = peewee.CharField(max_length=128, null=True)
is_admin = peewee.BooleanField(default=False)
class Meta:
db_table = 'users'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'email': self.email,
'is_admin': self.is_admin
}
def __unicode__(self):
return '%r, %r' % (self.name, self.email)
def hash_password(self, password):
self.password_hash = pwd_context.encrypt(password)
def verify_password(self, password):
return self.password_hash and pwd_context.verify(password, self.password_hash)
class QueryResult(db.Model):
id = peewee.PrimaryKeyField()
query_hash = peewee.CharField(max_length=32, index=True)
@ -56,7 +77,8 @@ class Query(BaseModel):
query_hash = peewee.CharField(max_length=32)
api_key = peewee.CharField(max_length=40)
ttl = peewee.IntegerField()
user = peewee.CharField(max_length=360)
user_email = peewee.CharField(max_length=360, null=True)
user = peewee.ForeignKeyField(User)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
class Meta:
@ -68,7 +90,7 @@ class Query(BaseModel):
type="TABLE", options="{}")
table_visualization.save()
def to_dict(self, with_result=True, with_stats=False, with_visualizations=False):
def to_dict(self, with_result=True, with_stats=False, with_visualizations=False, with_user=True):
d = {
'id': self.id,
'latest_query_data_id': self._data.get('latest_query_data', None),
@ -77,11 +99,15 @@ class Query(BaseModel):
'query': self.query,
'query_hash': self.query_hash,
'ttl': self.ttl,
'user': self.user,
'api_key': self.api_key,
'created_at': self.created_at,
}
if with_user:
d['user'] = self.user.to_dict()
else:
d['user_id'] = self._data['user']
if with_stats:
d['avg_runtime'] = self.avg_runtime
d['min_runtime'] = self.min_runtime
@ -100,20 +126,17 @@ class Query(BaseModel):
@classmethod
def all_queries(cls):
query = """SELECT queries.*, query_stats.*
FROM queries
LEFT OUTER JOIN
(SELECT qu.query_hash,
count(0) AS "times_retrieved",
avg(runtime) AS "avg_runtime",
min(runtime) AS "min_runtime",
max(runtime) AS "max_runtime",
max(retrieved_at) AS "last_retrieved_at"
FROM queries qu
JOIN query_results qr ON qu.query_hash=qr.query_hash
GROUP BY qu.query_hash) query_stats ON query_stats.query_hash = queries.query_hash
"""
return cls.raw(query)
q = Query.select(Query, User,
peewee.fn.Count(QueryResult.id).alias('times_retrieved'),
peewee.fn.Avg(QueryResult.runtime).alias('avg_runtime'),
peewee.fn.Min(QueryResult.runtime).alias('min_runtime'),
peewee.fn.Max(QueryResult.runtime).alias('max_runtime'),
peewee.fn.Max(QueryResult.retrieved_at).alias('last_retrieved_at'))\
.join(QueryResult, join_type=peewee.JOIN_LEFT_OUTER)\
.switch(Query).join(User)\
.group_by(Query.id, User.id)
return q
@classmethod
def update_instance(cls, query_id, **kwargs):
@ -131,17 +154,18 @@ LEFT OUTER JOIN
def _set_api_key(self):
if not self.api_key:
self.api_key = hashlib.sha1(
u''.join([str(time.time()), self.query, self.user, self.name])).hexdigest()
u''.join((str(time.time()), self.query, str(self._data['user']), self.name)).encode('utf-8')).hexdigest()
def __unicode__(self):
return unicode(self.id)
class Dashboard(db.Model):
class Dashboard(BaseModel):
id = peewee.PrimaryKeyField()
slug = peewee.CharField(max_length=140, index=True)
name = peewee.CharField(max_length=100)
user = peewee.CharField(max_length=360)
user_email = peewee.CharField(max_length=360, null=True)
user = peewee.ForeignKeyField(User)
layout = peewee.TextField()
is_archived = peewee.BooleanField(default=False, index=True)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
@ -153,8 +177,13 @@ class Dashboard(db.Model):
layout = json.loads(self.layout)
if with_widgets:
widgets = Widget.select(Widget, Visualization, Query, QueryResult).\
where(Widget.dashboard == self.id).join(Visualization).join(Query).join(QueryResult)
widgets = Widget.select(Widget, Visualization, Query, QueryResult, User)\
.where(Widget.dashboard == self.id)\
.join(Visualization)\
.join(Query)\
.join(User)\
.switch(Query)\
.join(QueryResult)
widgets = {w.id: w.to_dict() for w in widgets}
widgets_layout = map(lambda row: map(lambda widget_id: widgets.get(widget_id, None), row), layout)
else:
@ -164,14 +193,14 @@ class Dashboard(db.Model):
'id': self.id,
'slug': self.slug,
'name': self.name,
'user': self.user,
'user_id': self._data['user'],
'layout': layout,
'widgets': widgets_layout
}
@classmethod
def get_by_slug(cls, slug):
return cls.get(cls.slug==slug)
return cls.get(cls.slug == slug)
def save(self, *args, **kwargs):
if not self.slug:
@ -245,7 +274,7 @@ class Widget(db.Model):
def __unicode__(self):
return u"%s" % self.id
all_models = (QueryResult, Query, Dashboard, Visualization, Widget)
all_models = (User, QueryResult, Query, Dashboard, Visualization, Widget)
def create_db(create_tables, drop_tables):

View File

@ -1,3 +1,4 @@
import json
import os
import urlparse
@ -32,6 +33,11 @@ def array_from_string(str):
return array
def parse_boolean(str):
return json.loads(str.lower())
REDIS_URL = os.environ.get('REDASH_REDIS_URL', "redis://localhost:6379")
# "pg", "graphite" or "mysql"
@ -48,10 +54,12 @@ DATABASE_CONFIG = parse_db_url(os.environ.get("REDASH_DATABASE_URL", "postgresql
# Google Apps domain to allow access from; any user with email in this Google Apps will be allowed
# access
GOOGLE_APPS_DOMAIN = os.environ.get("REDASH_GOOGLE_APPS_DOMAIN", "")
GOOGLE_OPENID_ENABLED = parse_boolean(os.environ.get("REDASH_GOOGLE_OPENID_ENABLED", "true"))
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "false"))
# Email addresses of admin users (comma separated)
ADMINS = array_from_string(os.environ.get("REDASH_ADMINS", ''))
ALLOWED_EXTERNAL_USERS = array_from_string(os.environ.get("REDASH_ALLOWED_EXTERNAL_USERS", ''))
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../rd_ui/dist/"))
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../rd_ui/app/"))
WORKERS_COUNT = int(os.environ.get("REDASH_WORKERS_COUNT", "2"))
COOKIE_SECRET = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
LOG_LEVEL = os.environ.get("REDASH_LOG_LEVEL", "INFO")

View File

@ -1,6 +1,8 @@
Flask==0.10.1
Flask-GoogleAuth==0.4
Flask-RESTful==0.2.10
Flask-Login==0.2.9
passlib==1.6.2
Jinja2==2.7.2
MarkupSafe==0.18
WTForms==1.0.5

View File

@ -2,7 +2,7 @@ from unittest import TestCase
from redash import settings, db, app
import redash.models
# TODO: this isn't pretty... :-)
# TODO: this isn't pretty...
settings.DATABASE_CONFIG = {
'name': 'circle_test',
'engine': 'peewee.PostgresqlDatabase',

View File

@ -26,15 +26,33 @@ class ModelFactory(object):
kwargs = self._get_kwargs(override_kwargs)
return self.model.create(**kwargs)
class Sequence(object):
def __init__(self, string):
self.sequence = 0
self.string = string
def __call__(self):
self.sequence += 1
return self.string.format(self.sequence)
user_factory = ModelFactory(redash.models.User,
name='John Doe', email=Sequence('test{}@example.com'),
is_admin=False)
dashboard_factory = ModelFactory(redash.models.Dashboard,
name='test', user='test@everything.me', layout='[]')
name='test', user=user_factory.create, layout='[]')
query_factory = ModelFactory(redash.models.Query,
name='New Query',
description='',
query='SELECT 1',
ttl=-1,
user='test@everything.me')
user=user_factory.create)
query_result_factory = ModelFactory(redash.models.QueryResult,
data='{"columns":{}, "rows":[]}',

View File

@ -0,0 +1,66 @@
from unittest import TestCase
from mock import patch
from flask_googleauth import ObjectDict
from tests import BaseTestCase
from redash.authentication import validate_email, create_and_login_user
from redash import settings, models
from tests.factories import user_factory
class TestEmailValidation(TestCase):
def test_accepts_address_with_correct_domain(self):
with patch.object(settings, 'GOOGLE_APPS_DOMAIN', 'example.com'):
self.assertTrue(validate_email('example@example.com'))
def test_accepts_address_from_exception_list(self):
with patch.multiple(settings, GOOGLE_APPS_DOMAIN='example.com', ALLOWED_EXTERNAL_USERS=['whatever@whatever.com']):
self.assertTrue(validate_email('whatever@whatever.com'))
def test_accept_any_address_when_domain_empty(self):
with patch.object(settings, 'GOOGLE_APPS_DOMAIN', None):
self.assertTrue(validate_email('whatever@whatever.com'))
def test_rejects_address_with_incorrect_domain(self):
with patch.object(settings, 'GOOGLE_APPS_DOMAIN', 'example.com'):
self.assertFalse(validate_email('whatever@whatever.com'))
class TestCreateAndLoginUser(BaseTestCase):
def test_logins_valid_user(self):
user = user_factory.create(email='test@example.com')
with patch.object(settings, 'GOOGLE_APPS_DOMAIN', 'example.com'), patch('redash.authentication.login_user') as login_user_mock:
create_and_login_user(None, user)
login_user_mock.assert_called_once_with(user, remember=True)
def test_creates_vaild_new_user(self):
openid_user = ObjectDict({'email': 'test@example.com', 'name': 'Test User'})
with patch.multiple(settings, GOOGLE_APPS_DOMAIN='example.com', ADMINS=['admin@example.com']), \
patch('redash.authentication.login_user') as login_user_mock:
create_and_login_user(None, openid_user)
self.assertTrue(login_user_mock.called)
user = models.User.get(models.User.email == openid_user.email)
self.assertFalse(user.is_admin)
def test_creates_vaild_new_user_and_sets_is_admin(self):
openid_user = ObjectDict({'email': 'admin@example.com', 'name': 'Test User'})
with patch.multiple(settings, GOOGLE_APPS_DOMAIN='example.com', ADMINS=['admin@example.com']), \
patch('redash.authentication.login_user') as login_user_mock:
create_and_login_user(None, openid_user)
self.assertTrue(login_user_mock.called)
user = models.User.get(models.User.email == openid_user.email)
self.assertTrue(user.is_admin)
def test_ignores_invliad_user(self):
user = ObjectDict({'email': 'test@whatever.com'})
with patch.object(settings, 'GOOGLE_APPS_DOMAIN', 'example.com'), patch('redash.authentication.login_user') as login_user_mock:
create_and_login_user(None, user)
self.assertFalse(login_user_mock.called)

View File

@ -2,9 +2,12 @@ from contextlib import contextmanager
import json
import time
from unittest import TestCase
from flask import url_for
from flask.ext.login import current_user
from mock import patch
from tests import BaseTestCase
from tests.factories import dashboard_factory, widget_factory, visualization_factory, query_factory, \
query_result_factory
query_result_factory, user_factory
from redash import app, models, settings
from redash.utils import json_dumps
from redash.authentication import sign
@ -13,9 +16,12 @@ from redash.authentication import sign
settings.GOOGLE_APPS_DOMAIN = "example.com"
@contextmanager
def authenticated_user(c, user='test@example.com', name='John Test'):
def authenticated_user(c, user=None):
if not user:
user = user_factory.create()
with c.session_transaction() as sess:
sess['openid'] = {'email': user, 'name': name}
sess['user_id'] = user.id
yield
@ -48,40 +54,12 @@ class AuthenticationTestMixin():
self.assertEquals(200, rv.status_code)
class TestAuthentication(TestCase):
class TestAuthentication(BaseTestCase):
def test_redirects_for_nonsigned_in_user(self):
with app.test_client() as c:
rv = c.get("/")
self.assertEquals(302, rv.status_code)
def test_returns_content_when_authenticated_with_correct_domain(self):
settings.GOOGLE_APPS_DOMAIN = "example.com"
with app.test_client() as c, authenticated_user(c, user="test@example.com"):
rv = c.get("/")
self.assertEquals(200, rv.status_code)
def test_redirects_when_authenticated_with_wrong_domain(self):
settings.GOOGLE_APPS_DOMAIN = "example.com"
with app.test_client() as c, authenticated_user(c, user="test@not-example.com"):
rv = c.get("/")
self.assertEquals(302, rv.status_code)
def test_returns_content_when_user_in_allowed_list(self):
settings.GOOGLE_APPS_DOMAIN = "example.com"
settings.ALLOWED_EXTERNAL_USERS = ["test@not-example.com"]
with app.test_client() as c, authenticated_user(c, user="test@not-example.com"):
rv = c.get("/")
self.assertEquals(200, rv.status_code)
def test_returns_content_when_google_apps_domain_empty(self):
settings.GOOGLE_APPS_DOMAIN = ""
settings.ALLOWED_EXTERNAL_USERS = []
with app.test_client() as c, authenticated_user(c, user="test@whatever.com"):
rv = c.get("/")
self.assertEquals(200, rv.status_code)
class PingTest(TestCase):
def test_ping(self):
@ -121,13 +99,13 @@ class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.status_code, 404)
def test_create_new_dashboard(self):
user_email = 'test@example.com'
with app.test_client() as c, authenticated_user(c, user=user_email):
user = user_factory.create()
with app.test_client() as c, authenticated_user(c, user=user):
dashboard_name = 'Test Dashboard'
rv = json_request(c.post, '/api/dashboards', data={'name': dashboard_name})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'Test Dashboard')
self.assertEquals(rv.json['user'], user_email)
self.assertEquals(rv.json['user_id'], user.id)
self.assertEquals(rv.json['layout'], [])
def test_update_dashboard(self):
@ -220,7 +198,7 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.json['name'], 'Testing')
def test_create_query(self):
user = 'test@example.com'
user = user_factory.create()
query_data = {
'name': 'Testing',
'query': 'SELECT 1',
@ -232,7 +210,7 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.status_code, 200)
self.assertDictContainsSubset(query_data, rv.json)
self.assertEquals(rv.json['user'], user)
self.assertEquals(rv.json['user']['id'], user.id)
self.assertIsNotNone(rv.json['api_key'])
self.assertIsNotNone(rv.json['query_hash'])
@ -358,3 +336,105 @@ class CsvQueryResultAPITest(BaseTestCase, AuthenticationTestMixin):
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id))
self.assertEquals(rv.status_code, 200)
class TestLogin(BaseTestCase):
def setUp(self):
settings.PASSWORD_LOGIN_ENABLED = True
super(TestLogin, self).setUp()
def test_redirects_to_google_login_if_password_disabled(self):
with app.test_client() as c, patch.object(settings, 'PASSWORD_LOGIN_ENABLED', False):
rv = c.get('/login')
self.assertEquals(rv.status_code, 302)
self.assertTrue(rv.location.endswith(url_for('GoogleAuth.login')))
def test_get_login_form(self):
with app.test_client() as c:
rv = c.get('/login')
self.assertEquals(rv.status_code, 200)
def test_submit_non_existing_user(self):
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': 'arik', 'password': 'password'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
def test_submit_correct_user_and_password(self):
user = user_factory.create()
user.hash_password('password')
user.save()
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': user.email, 'password': 'password'})
self.assertEquals(rv.status_code, 302)
login_user_mock.assert_called_with(user, remember=False)
def test_submit_correct_user_and_password_and_remember_me(self):
user = user_factory.create()
user.hash_password('password')
user.save()
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': user.email, 'password': 'password', 'remember': True})
self.assertEquals(rv.status_code, 302)
login_user_mock.assert_called_with(user, remember=True)
def test_submit_correct_user_and_password_with_next(self):
user = user_factory.create()
user.hash_password('password')
user.save()
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login?next=/test',
data={'username': user.email, 'password': 'password'})
self.assertEquals(rv.status_code, 302)
self.assertEquals(rv.location, 'http://localhost/test')
login_user_mock.assert_called_with(user, remember=False)
def test_submit_incorrect_user(self):
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': 'non-existing', 'password': 'password'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
def test_submit_incorrect_password(self):
user = user_factory.create()
user.hash_password('password')
user.save()
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': user.email, 'password': 'badbadpassword'})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
def test_submit_incorrect_password(self):
user = user_factory.create()
with app.test_client() as c, patch('redash.controllers.login_user') as login_user_mock:
rv = c.post('/login', data={'username': user.email, 'password': ''})
self.assertEquals(rv.status_code, 200)
self.assertFalse(login_user_mock.called)
def test_user_already_loggedin(self):
with app.test_client() as c, authenticated_user(c), patch('redash.controllers.login_user') as login_user_mock:
rv = c.get('/login')
self.assertEquals(rv.status_code, 302)
self.assertFalse(login_user_mock.called)
# TODO: brute force protection?
class TestLogout(BaseTestCase):
def test_logout_when_not_loggedin(self):
with app.test_client() as c:
rv = c.get('/logout')
self.assertEquals(rv.status_code, 302)
self.assertFalse(current_user.is_authenticated())
def test_logout_when_loggedin(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/')
self.assertTrue(current_user.is_authenticated())
rv = c.get('/logout')
self.assertEquals(rv.status_code, 302)
self.assertFalse(current_user.is_authenticated())

View File

@ -8,10 +8,10 @@ class DashboardTest(BaseTestCase):
d1 = dashboard_factory.create()
self.assertEquals(d1.slug, 'test')
d2 = dashboard_factory.create()
d2 = dashboard_factory.create(user=d1.user)
self.assertNotEquals(d1.slug, d2.slug)
d3 = dashboard_factory.create()
d3 = dashboard_factory.create(user=d1.user)
self.assertNotEquals(d1.slug, d3.slug)
self.assertNotEquals(d2.slug, d3.slug)