mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
23 Commits
v0.3.2+b11
...
v0.3.2+b13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5babab85c8 | ||
|
|
8debd01a36 | ||
|
|
51a37cae3d | ||
|
|
3cbdae6e5c | ||
|
|
edcf0661a6 | ||
|
|
6d14c5c555 | ||
|
|
a0662d5323 | ||
|
|
cbd1cf7c25 | ||
|
|
a55225b5e8 | ||
|
|
b81c3ba614 | ||
|
|
2d0998a995 | ||
|
|
766840de68 | ||
|
|
791f2e0b34 | ||
|
|
9241a7c35d | ||
|
|
dda92477cf | ||
|
|
07455e5821 | ||
|
|
1b9aae0137 | ||
|
|
30b86ea781 | ||
|
|
a186d44d8f | ||
|
|
574f75b293 | ||
|
|
252ae7455a | ||
|
|
72065c0ee2 | ||
|
|
07caee1d12 |
@@ -1,3 +0,0 @@
|
||||
cookbook 'apt'
|
||||
cookbook 'postgresql'
|
||||
cookbook 'redash', git: 'git@github.com:EverythingMe/chef-redash.git'
|
||||
2
Procfile
2
Procfile
@@ -1 +1 @@
|
||||
web: honcho start -f Honchofile -p $PORT
|
||||
web: honcho start -f Procfile.heroku -p $PORT
|
||||
|
||||
2
Procfile.heroku
Normal file
2
Procfile.heroku
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT --host 0.0.0.0 -d -r
|
||||
worker: ./manage.py runworkers
|
||||
16
README.md
16
README.md
@@ -46,14 +46,8 @@ It's very likely that in the future we will switch to [D3.js](http://d3js.org/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Download the [latest release](https://github.com/everythingme/redash/releases).
|
||||
2. Make sure you have `Python` v2.7, `pip`, PostgreSQL and Redis installed.
|
||||
3. Install Python requirements: `pip install -r requirements.txt`.
|
||||
4. Make a copy of the examples settings file: `cp redash/settings_example.py redash/settings.py` and edit the relevant settings.
|
||||
5. Create database: `./manage.py database create_tables`.
|
||||
6. Start the web server: `./manage.py runserver`.
|
||||
7. Start the worker: `./manage.py runworker`.
|
||||
8. Open `http://localhost:5000/` and query away.
|
||||
* [Setting up re:dash on Heroku in 5 minutes](https://github.com/EverythingMe/redash/wiki/Setting-up-re:dash-on-Heroku-in-5-minutes)
|
||||
* [Setting re:dash on your own server (Ubuntu)](https://github.com/EverythingMe/redash/wiki/Setting-re:dash-on-your-own-server-(Ubuntu))
|
||||
|
||||
**Need help setting re:dash or one of the dependencies up?** Ping @arikfr on the IRC #redash channel or send a message to the [mailing list](https://groups.google.com/forum/#!forum/redash-users), and he will gladly help.
|
||||
|
||||
@@ -61,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).
|
||||
|
||||
60
Vagrantfile
vendored
60
Vagrantfile
vendored
@@ -1,60 +0,0 @@
|
||||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
|
||||
VAGRANTFILE_API_VERSION = '2'
|
||||
|
||||
POSTGRES_PASSWORD = 'securepass'
|
||||
|
||||
# Currently, chef postgress cookbook works with cleartext paswords,
|
||||
# unless the password begins with 'md5'
|
||||
# See https://github.com/hw-cookbooks/postgresql/issues/95
|
||||
require "digest/md5"
|
||||
postgres_password_md5 = 'md5'+Digest::MD5.hexdigest(POSTGRES_PASSWORD+'postgres')
|
||||
|
||||
# After starting the vagrant machine, the application is accessible via the URL
|
||||
# http://localhost:9999
|
||||
HOST_PORT_TO_FORWARD_TO_REDASH = 9999
|
||||
|
||||
# Deploy direcly the code in parent dir; Don't download a release tarball
|
||||
live_testing_deployment = true
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = 'ubuntu-precise-cloudimg-amd64'
|
||||
config.vm.box_url = 'http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box'
|
||||
|
||||
if config.respond_to? :cache
|
||||
config.cache.auto_detect = true
|
||||
end
|
||||
|
||||
config.berkshelf.enabled = true
|
||||
config.omnibus.chef_version = :latest
|
||||
|
||||
config.vm.network 'forwarded_port', guest: 8888, host: HOST_PORT_TO_FORWARD_TO_REDASH
|
||||
|
||||
if live_testing_deployment
|
||||
config.vm.synced_folder "..", "/opt/redash"
|
||||
end
|
||||
|
||||
config.vm.provision :chef_solo do |chef|
|
||||
# run apt-get update before anything else (specifically postgresql)..
|
||||
chef.add_recipe 'apt'
|
||||
chef.add_recipe 'redash::redis_for_redash'
|
||||
chef.add_recipe 'postgresql::client'
|
||||
chef.add_recipe 'postgresql::server'
|
||||
chef.add_recipe 'redash::redash_pg_schema'
|
||||
chef.add_recipe 'redash::redash'
|
||||
# chef.log_level = :debug
|
||||
chef.json = {
|
||||
'apt' => { 'compiletime' => true },
|
||||
'postgresql' => { 'password' => {'postgres' => postgres_password_md5 } },
|
||||
'redash' => { 'db' => {'host' => 'localhost',
|
||||
'user' => 'postgres',
|
||||
'password' => POSTGRES_PASSWORD },
|
||||
'allow' => {'google_app_domain' => 'gmail.com',
|
||||
'admins' => ['joe@egmail.com','jack@gmail.com']},
|
||||
'install_tarball' => !live_testing_deployment,
|
||||
'user' => live_testing_deployment ? 'vagrant' : 'redash'}
|
||||
}
|
||||
end
|
||||
end
|
||||
56
migrations/create_users.py
Normal file
56
migrations/create_users.py
Normal 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)
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
(function () {
|
||||
var DashboardCtrl = function ($scope, $routeParams, $http, Dashboard) {
|
||||
var DashboardCtrl = function ($scope, $routeParams, $http, $timeout, Dashboard) {
|
||||
$scope.refreshEnabled = false;
|
||||
$scope.refreshRate = 60;
|
||||
$scope.dashboard = Dashboard.get({slug: $routeParams.dashboardSlug}, function(dashboard) {
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
});
|
||||
|
||||
var autoRefresh = function() {
|
||||
if ($scope.refreshEnabled) {
|
||||
$timeout(function() {
|
||||
Dashboard.get({slug: $routeParams.dashboardSlug}, function(dashboard) {
|
||||
var newWidgets = _.groupBy(_.flatten(dashboard.widgets), 'id');
|
||||
|
||||
_.each($scope.dashboard.widgets, function(row) {
|
||||
_.each(row, function(widget, i) {
|
||||
var newWidget = newWidgets[widget.id];
|
||||
if (newWidget && newWidget[0].visualization.query.latest_query_data_id != widget.visualization.query.latest_query_data_id ) {
|
||||
row[i] = newWidget[0];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoRefresh();
|
||||
});
|
||||
|
||||
}, $scope.refreshRate);
|
||||
};
|
||||
}
|
||||
|
||||
$scope.triggerRefresh = function(){
|
||||
$scope.refreshEnabled = !$scope.refreshEnabled;
|
||||
|
||||
if ($scope.refreshEnabled) {
|
||||
var refreshRate = _.min(_.flatten($scope.dashboard.widgets), function(widget) {
|
||||
return widget.visualization.query.ttl;
|
||||
}).visualization.query.ttl;
|
||||
|
||||
$scope.refreshRate = _.max([120, refreshRate * 2])*1000;
|
||||
|
||||
autoRefresh();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var WidgetCtrl = function ($scope, $http, $location, Query) {
|
||||
@@ -143,6 +181,7 @@
|
||||
|
||||
$scope.refreshOptions = [
|
||||
{value: -1, name: 'No Refresh'},
|
||||
{value: 60, name: 'Every minute'},
|
||||
]
|
||||
|
||||
_.each(_.range(1, 13), function(i) {
|
||||
@@ -200,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);
|
||||
}
|
||||
|
||||
@@ -264,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';
|
||||
@@ -291,7 +330,7 @@
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user'
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
@@ -390,7 +429,7 @@
|
||||
}
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl])
|
||||
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', '$timeout', 'Dashboard', DashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', 'Visualization', QueryFiddleCtrl])
|
||||
|
||||
@@ -213,4 +213,8 @@ to add those CSS styles here. */
|
||||
color: white;
|
||||
background-color: #FF8080;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
visualization-renderer > div {
|
||||
overflow: scroll;
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<div class="container">
|
||||
<h2 id="dashboard_title">
|
||||
{{dashboard.name}}
|
||||
|
||||
<button type="button" class="btn btn-default btn-xs" ng-class="{active: refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()"><span class="glyphicon glyphicon-refresh"></span></button>
|
||||
<span ng-show="dashboard.canEdit()">
|
||||
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span
|
||||
class="glyphicon glyphicon-cog"></span></button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,6 @@ app = Flask(__name__,
|
||||
static_folder=settings.STATIC_ASSETS_PATH,
|
||||
static_path='/static')
|
||||
|
||||
|
||||
api = Api(app)
|
||||
|
||||
# configure our database
|
||||
@@ -33,7 +32,11 @@ def json_representation(data, code, headers=None):
|
||||
|
||||
|
||||
redis_url = urlparse.urlparse(settings.REDIS_URL)
|
||||
redis_connection = redis.StrictRedis(host=redis_url.hostname, port=redis_url.port, db=0, password=redis_url.password)
|
||||
if redis_url.path:
|
||||
redis_db = redis_url.path[1]
|
||||
else:
|
||||
redis_db = 0
|
||||
redis_connection = redis.StrictRedis(host=redis_url.hostname, port=redis_url.port, db=redis_db, password=redis_url.password)
|
||||
|
||||
from redash import data
|
||||
data_manager = data.Manager(redis_connection, db)
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
import time
|
||||
from werkzeug.contrib.fixers import ProxyFix
|
||||
from redash import models, settings
|
||||
@@ -67,6 +67,25 @@ class HMACAuthentication(object):
|
||||
return decorated
|
||||
|
||||
|
||||
def create_user(_, user):
|
||||
try:
|
||||
u = models.User.get(models.User.email == user.email)
|
||||
if u.name != user.name:
|
||||
current_app.logger.debug("Updating user name (%r -> %r)", u.name, user.name)
|
||||
u.name = user.name
|
||||
u.save()
|
||||
except models.User.DoesNotExist:
|
||||
current_app.logger.debug("Creating user object (%r)", user.name)
|
||||
u = models.User(name=user.name, email=user.email)
|
||||
u.save()
|
||||
|
||||
user['id'] = u.id
|
||||
user['is_admin'] = u.is_admin
|
||||
|
||||
|
||||
login.connect(create_user)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -39,8 +39,10 @@ def index(anything=None):
|
||||
|
||||
user = {
|
||||
'gravatar_url': gravatar_url,
|
||||
'is_admin': g.user['email'] in settings.ADMINS,
|
||||
'name': g.user['email']
|
||||
'is_admin': g.user['is_admin'],
|
||||
'id': g.user['id'],
|
||||
'name': g.user['name'],
|
||||
'email': g.user['email']
|
||||
}
|
||||
|
||||
return render_template("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
|
||||
@@ -80,9 +82,16 @@ 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']
|
||||
if not self._user:
|
||||
self._user = models.User(id=g.user['id'], email=g.user['email'], name=g.user['name'],
|
||||
is_admin=g.user['is_admin'])
|
||||
return self._user
|
||||
|
||||
|
||||
class DashboardListAPI(BaseResource):
|
||||
@@ -111,9 +120,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 +207,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:
|
||||
|
||||
@@ -158,7 +158,12 @@ class Manager(object):
|
||||
runner = query_runner_mysql.mysql(connection_string)
|
||||
elif connection_type == 'graphite':
|
||||
from redash.data import query_runner_graphite
|
||||
runner = query_runner_graphite.graphite(connection_string)
|
||||
connection_params = json.loads(connection_string)
|
||||
if connection_params['auth']:
|
||||
connection_params['auth'] = tuple(connection_params['auth'])
|
||||
else:
|
||||
connection_params['auth'] = None
|
||||
runner = query_runner_graphite.graphite(connection_params)
|
||||
else:
|
||||
from redash.data import query_runner
|
||||
runner = query_runner.redshift(connection_string)
|
||||
|
||||
@@ -43,7 +43,10 @@ def mysql(connection_string):
|
||||
cursor.close()
|
||||
except MySQLdb.Error, e:
|
||||
json_data = None
|
||||
error = e.message
|
||||
error = e.args[1]
|
||||
except KeyboardInterrupt:
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
finally:
|
||||
|
||||
@@ -7,21 +7,33 @@ 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):
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField(max_length=320)
|
||||
email = peewee.CharField(max_length=320, index=True, unique=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)
|
||||
|
||||
|
||||
class QueryResult(db.Model):
|
||||
id = peewee.PrimaryKeyField()
|
||||
query_hash = peewee.CharField(max_length=32, index=True)
|
||||
@@ -56,7 +68,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 +81,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 +90,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 +117,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 +145,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 +168,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 +184,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 +265,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):
|
||||
|
||||
@@ -39,7 +39,7 @@ CONNECTION_ADAPTER = os.environ.get("REDASH_CONNECTION_ADAPTER", "pg")
|
||||
# Connection string for the database that is used to run queries against. Examples:
|
||||
# -- mysql: CONNECTION_STRING = "Server=;User=;Pwd=;Database="
|
||||
# -- pg: CONNECTION_STRING = "user= password= host= port=5439 dbname="
|
||||
# -- graphite: CONNECTION_STRING = {'url': 'https://graphite.yourcompany.com', 'auth': ('user', 'password'), 'verify': True}
|
||||
# -- graphite: CONNECTION_STRING = {"url": "https://graphite.yourcompany.com", "auth": ["user", "password"], "verify": true}
|
||||
CONNECTION_STRING = os.environ.get("REDASH_CONNECTION_STRING", "user= password= host= port=5439 dbname=")
|
||||
|
||||
# Connection settings for re:dash's own database (where we store the queries, results, etc)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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":[]}',
|
||||
|
||||
@@ -4,7 +4,7 @@ import time
|
||||
from unittest import TestCase
|
||||
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 +13,13 @@ 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['openid'] = {'email': user.email, 'name': user.name,
|
||||
'id': user.id, 'is_admin': user.is_admin}
|
||||
|
||||
yield
|
||||
|
||||
@@ -48,7 +52,7 @@ 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("/")
|
||||
@@ -56,13 +60,13 @@ class TestAuthentication(TestCase):
|
||||
|
||||
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"):
|
||||
with app.test_client() as c, authenticated_user(c, user=user_factory.create(email="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"):
|
||||
with app.test_client() as c, authenticated_user(c, user=user_factory.create(email="test@not-example.com")):
|
||||
rv = c.get("/")
|
||||
self.assertEquals(302, rv.status_code)
|
||||
|
||||
@@ -70,7 +74,7 @@ class TestAuthentication(TestCase):
|
||||
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"):
|
||||
with app.test_client() as c, authenticated_user(c, user=user_factory.create(email="test@not-example.com")):
|
||||
rv = c.get("/")
|
||||
self.assertEquals(200, rv.status_code)
|
||||
|
||||
@@ -78,7 +82,7 @@ class TestAuthentication(TestCase):
|
||||
settings.GOOGLE_APPS_DOMAIN = ""
|
||||
settings.ALLOWED_EXTERNAL_USERS = []
|
||||
|
||||
with app.test_client() as c, authenticated_user(c, user="test@whatever.com"):
|
||||
with app.test_client() as c, authenticated_user(c, user=user_factory.create(email="test@whatever.com")):
|
||||
rv = c.get("/")
|
||||
self.assertEquals(200, rv.status_code)
|
||||
|
||||
@@ -121,13 +125,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 +224,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 +236,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'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user