Compare commits

...

35 Commits

Author SHA1 Message Date
Arik Fraimovich
1b9aae0137 Merge pull request #102 from EverythingMe/refresh_button
Only refresh widgets that have their query data updated.
2014-02-17 17:59:27 +02:00
Arik Fraimovich
30b86ea781 Only refresh widgets that have their query data updated. 2014-02-17 17:57:26 +02:00
Arik Fraimovich
a186d44d8f Merge pull request #101 from EverythingMe/refresh_button
Auto-refresh button for dashboards & every minute refresh rate
2014-02-17 17:22:22 +02:00
Arik Fraimovich
574f75b293 Option to set every minute refresh rate. 2014-02-17 17:19:48 +02:00
Arik Fraimovich
252ae7455a Auto-refresh button for dashboards. 2014-02-17 17:19:32 +02:00
Arik Fraimovich
72065c0ee2 Merge pull request #99 from EverythingMe/feature_allow_external_users
Procfile changes:
2014-02-13 20:21:08 +02:00
Arik Fraimovich
07caee1d12 Procfile changes:
1. Renamed Honchofile -> Procfile.heroku and changed it to work better with Heroku.
2. Added Procfile.dev for development.
2014-02-13 20:16:36 +02:00
Arik Fraimovich
4c3904760c Merge pull request #98 from EverythingMe/feature_allow_external_users
Feature: allow external users
2014-02-13 20:15:54 +02:00
Arik Fraimovich
8ad2c2a59e If only domain specified and not external users, use federated login. 2014-02-13 20:13:08 +02:00
Arik Fraimovich
e5a365ba41 Bring back the ability to set allowed external users & publicly open re:dash. 2014-02-13 20:04:28 +02:00
Arik Fraimovich
fc0b118188 Merge pull request #96 from EverythingMe/fix_description_nullable
Fix: allow queries.description to be null (+ migration)
2014-02-13 19:18:39 +02:00
Arik Fraimovich
a207b93d0d Fix: allow queries.description to be null. 2014-02-13 19:08:35 +02:00
Arik Fraimovich
b1d588b1f2 Merge pull request #95 from EverythingMe/feature_stacking_selection
Allow user to set the stacking of the chart.
2014-02-13 16:24:39 +02:00
Arik Fraimovich
95a6bab8b5 Allow user to set the stacking of the chart. 2014-02-13 16:19:15 +02:00
Arik Fraimovich
c82433e6b4 CirlceCI: no longer need to delete settings.py. 2014-02-13 14:50:42 +02:00
Arik Fraimovich
2e84852519 Merge pull request #94 from EverythingMe/fix_query_hash_not_updating
Fix: when updating query text the hash should change.
2014-02-13 13:13:38 +02:00
Arik Fraimovich
da746d15a0 Fix: when updating query text the hash should change. 2014-02-13 13:08:48 +02:00
Arik Fraimovich
1b519269d8 Merge pull request #93 from EverythingMe/feature_env
Feature: better Heroku support - move configuration to environment variables & Procfile
2014-02-13 12:15:52 +02:00
Arik Fraimovich
5ffaf1aead Fix CircleCI configuration 2014-02-12 21:37:56 +02:00
Arik Fraimovich
b704406164 Example .env file. 2014-02-12 20:53:32 +02:00
Arik Fraimovich
5c9fe40702 Bump version. 2014-02-12 20:52:36 +02:00
Arik Fraimovich
fe7c4f96aa Fix: allow passing relative path for assets. 2014-02-12 20:52:19 +02:00
Arik Fraimovich
83909a07fa Read settings from environment variables instead of a settings file.
This is mostly done to make it easier to run re:dash on Heroku but should be convenient in other platforms too.
2014-02-12 20:43:41 +02:00
Arik Fraimovich
cd99927881 Add Honcho (foreman alternative in Python) file(s).
The reason we have both Procfile and Honchofile is to be able to run both the workers and the web server in a single dyno on Heroku.
2014-02-12 20:42:32 +02:00
Arik Fraimovich
8bbb485d5b Rename test files to test_. 2014-02-12 20:41:36 +02:00
Arik Fraimovich
b2ec77668e Merge pull request #89 from EverythingMe/feature_pie_chart
Feature: pie charts
2014-02-11 16:46:12 +02:00
Arik Fraimovich
f8302ab65a Better support for single series tooltips. 2014-02-11 16:30:41 +02:00
Arik Fraimovich
e632cf1c42 Support for pie charts. 2014-02-11 16:30:23 +02:00
Arik Fraimovich
640557df4f Merge pull request #88 from EverythingMe/feature_graphite_v2
Feature: graphite query runner
2014-02-11 11:47:11 +02:00
Arik Fraimovich
9b7227a88b Make the default newOptions apply to all but the chart vis 2014-02-11 11:42:37 +02:00
Arik Fraimovich
aabc912862 Graphite query runner support 2014-02-11 11:38:34 +02:00
Arik Fraimovich
02d6567347 Imrpove (?) line chart settings 2014-02-11 11:38:01 +02:00
Arik Fraimovich
6f8767d1fc Merge pull request #87 from EverythingMe/fix_viz
Some more visualizations UI updates
2014-02-10 21:11:13 +02:00
Arik Fraimovich
bc787efc86 Show delete/edit/create new visualization only to query owner.
This is a temporary solution until we have owners for visualizations.
2014-02-10 21:06:52 +02:00
Arik Fraimovich
e0d46c3942 When clicking on widget in dashboard, it should take to the correct visualization tab. 2014-02-10 19:38:23 +02:00
28 changed files with 429 additions and 113 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
REDASH_CONNECTION_ADAPTER=pg
REDASH_CONNECTION_STRING="dbname=data"
REDASH_STATIC_ASSETS_PATH=../rd_ui/app/
REDASH_GOOGLE_APPS_DOMAIN=
REDASH_ADMINS=
REDASH_WORKERS_COUNT=2
REDASH_COOKIE_SECRET=
REDASH_DATABASE_URL='postgresql://rd'
REDASH_LOG_LEVEL = "INFO"

2
.gitignore vendored
View File

@@ -2,7 +2,6 @@
.idea
*.pyc
.coverage
redash/settings.py
rd_ui/dist
.DS_Store
@@ -10,3 +9,4 @@ rd_ui/dist
.vagrant
Berksfile.lock
redash/dump.rdb
.env

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: honcho start -f Procfile.heroku -p $PORT

2
Procfile.dev Normal file
View File

@@ -0,0 +1,2 @@
web: ./manage.py runserver -p $PORT
worker: ./manage.py runworkers

2
Procfile.heroku Normal file
View File

@@ -0,0 +1,2 @@
web: ./manage.py runserver -p $PORT --host 0.0.0.0 -d -r
worker: ./manage.py runworkers

View File

@@ -10,7 +10,6 @@ dependencies:
- make deps
- pip install requests coverage nose
- pip install -r requirements.txt
- cp redash/settings_example.py redash/settings.py
cache_directories:
- rd_ui/node_modules/
- rd_ui/app/bower_components/
@@ -18,7 +17,6 @@ test:
override:
- make test
post:
- rm redash/settings.py
- make pack
deployment:
github:

View File

@@ -20,6 +20,7 @@ def version():
"""Displays re:dash version."""
print __version__
@manager.command
def runworkers():
"""Starts the re:dash query executors/workers."""

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.set_nullable(models.Query, models.Query.description, True)
db.close_db(None)

View File

@@ -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) {
@@ -20,8 +58,9 @@
});
};
$scope.open = function(query) {
$scope.open = function(query, visualization) {
$location.path('/queries/' + query.id);
$location.hash(visualization.id);
}
$scope.query = new Query($scope.widget.visualization.query);
@@ -142,6 +181,7 @@
$scope.refreshOptions = [
{value: -1, name: 'No Refresh'},
{value: 60, name: 'Every minute'},
]
_.each(_.range(1, 13), function(i) {
@@ -389,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])

View File

@@ -65,9 +65,18 @@
'Line': 'line',
'Column': 'column',
'Area': 'area',
'Scatter': 'scatter'
'Scatter': 'scatter',
'Pie': 'pie'
};
scope.stackingOptions = {
"None": "none",
"Normal": "normal",
"Percent": "percent"
};
scope.stacking = "none";
if (!scope.vis) {
// create new visualization
// wait for query to load to populate with defaults
@@ -79,32 +88,56 @@
'type': Visualization.prototype.TYPES.CHART,
'name': '',
'description': q.description || '',
'options': newOptions()
'options': newOptions(Visualization.prototype.TYPES.CHART)
};
}
}, true);
}
function newOptions(chartType) {
if (chartType === Visualization.prototype.TYPES.COHORT) {
// empty config at the moment
return {};
}
// Chart
return {
'series': {
'type': scope.seriesTypes[0]
}
if (chartType === Visualization.prototype.TYPES.CHART) {
return {
'series': {
'type': 'column',
'stacking': null
}
};
};
return {};
}
var chartOptionsUnwatch = null;
scope.$watch('vis.type', function(type) {
// if not edited by user, set name to match type
if (type && scope.vis && !scope.visForm.name.$dirty) {
// poor man's titlecase
scope.vis.name = scope.vis.type[0] + scope.vis.type.slice(1).toLowerCase();
}
if (type && type == Visualization.prototype.TYPES.CHART) {
if (scope.vis.options.series.stacking === null) {
scope.stacking = "none";
} else if (scope.vis.options.series.stacking === undefined) {
scope.stacking = "normal";
} else {
scope.stacking = scope.vis.options.series.stacking ;
}
chartOptionsUnwatch = scope.$watch("stacking", function(stacking) {
if (stacking == "none") {
scope.vis.options.series.stacking = null;
} else {
scope.vis.options.series.stacking = stacking;
}
});
} else {
if (chartOptionsUnwatch) {
chartOptionsUnwatch();
chartOptionsUnwatch = null;
}
}
});
scope.toggleAdvancedMode = function() {
@@ -112,7 +145,7 @@
};
scope.typeChanged = function() {
scope.vis.options = newOptions();
scope.vis.options = newOptions(scope.vis.type);
};
scope.submit = function() {

View File

@@ -33,10 +33,20 @@
}
});
} else {
var s = "<b>" + this.points[0].key + "</b>";
$.each(this.points, function (i, point) {
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' +
Highcharts.numberFormat(point.y);
var points = this.points;
var name = points[0].key || points[0].name;
var s = "<b>" + name + "</b>";
$.each(points, function (i, point) {
if (points.length > 1) {
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' + Highcharts.numberFormat(point.y);
} else {
s += ": " + Highcharts.numberFormat(point.y);
if (point.percentage < 100) {
s += ' (' +Highcharts.numberFormat(point.percentage) + '%)';
}
}
});
}
@@ -95,15 +105,28 @@
},
line: {
marker: {
radius: 3,
radius: 1
},
lineWidth: 1,
lineWidth: 2,
states: {
hover: {
lineWidth: 2
lineWidth: 2,
marker: {
radius: 3
}
}
}
},
pie: {
allowPointSelect: true,
cursor: 'pointer',
dataLabels: {
enabled: true,
color: '#000000',
connectorColor: '#000000',
format: '<b>{point.name}</b>: {point.percentage:.1f} %'
}
},
scatter: {
marker: {
radius: 5,

View File

@@ -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>
@@ -21,7 +23,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title" style="cursor: pointer;" ng-click="open(query)">
<h3 class="panel-title" style="cursor: pointer;" ng-click="open(query, widget.visualization)">
<p>
<span ng-bind="query.name"></span>
</p>
@@ -37,7 +39,7 @@
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<span class="pull-right">
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}"><span class="glyphicon glyphicon-link"></span></a>
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}#{{widget.visualization.id}}"><span class="glyphicon glyphicon-link"></span></a>
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
</span>

View File

@@ -12,6 +12,9 @@
<div class="form-group" ng-show="vis.type == visTypes.Chart">
<label class="control-label">Chart Type</label>
<select required ng-model="vis.options.series.type" ng-options="value as key for (key, value) in seriesTypes" class="form-control"></select>
<label class="control-label">Stacking</label>
<select required ng-model="stacking" ng-options="value as key for (key, value) in stackingOptions" class="form-control"></select>
</div>
<div class="form-group">

View File

@@ -56,9 +56,9 @@
<rd-tab id="pivot" name="Pivot Table"></rd-tab>
<!-- hide the table visualization -->
<rd-tab id="{{vis.id}}" name="{{vis.name}}" ng-hide="vis.type=='TABLE'" ng-repeat="vis in query.visualizations">
<span class="remove" ng-click="deleteVisualization($event, vis)"> &times;</span>
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="currentUser.canEdit(query)"> &times;</span>
</rd-tab>
<rd-tab id="add" name="&plus;New" removeable="true"></rd-tab>
<rd-tab id="add" name="&plus;New" removeable="true" ng-show="currentUser.canEdit(query)"></rd-tab>
</ul>
<div class="col-lg-12" ng-show="selectedTab == 'table'">
@@ -70,12 +70,16 @@
</div>
<div class="col-lg-12" ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
<div class="row">
<div class="row" ng-show="currentUser.canEdit(query)">
<p>
<div class="col-lg-6">
<div class="col-lg-12">
<edit-visulatization-form vis="vis" query="query"></edit-visulatization-form>
</div>
<div class="col-lg-6">
</p>
</div>
<div class="row">
<p>
<div class="col-lg-12">
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
</div>
</p>

View File

@@ -7,7 +7,7 @@ from flask_peewee.db import Database
import redis
from redash import settings, utils
__version__ = '0.3.1'
__version__ = '0.3.2'
app = Flask(__name__,
template_folder=settings.STATIC_ASSETS_PATH,

View File

@@ -1,11 +1,10 @@
import functools
import hashlib
import hmac
from flask import request, make_response
from flask.ext.googleauth import GoogleFederated
from flask import current_app, request, make_response, g, redirect, url_for
from flask.ext.googleauth import GoogleAuth
import time
from werkzeug.contrib.fixers import ProxyFix
import werkzeug.wrappers
from redash import models, settings
@@ -23,36 +22,57 @@ class HMACAuthentication(object):
def __init__(self, auth):
self.auth = auth
def required(self, fn):
wrapped_fn = self.auth.required(fn)
@staticmethod
def api_key_authentication():
signature = request.args.get('signature')
expires = float(request.args.get('expires') or 0)
query_id = request.view_args.get('query_id', None)
# TODO: 3600 should be a setting
if signature and query_id and time.time() < expires <= time.time() + 3600:
query = models.Query.get(models.Query.id == query_id)
calculated_signature = sign(query.api_key, request.path, expires)
if query.api_key and signature == calculated_signature:
return True
return False
@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)
def required(self, fn):
@functools.wraps(fn)
def decorated(*args, **kwargs):
signature = request.args.get('signature')
expires = float(request.args.get('expires') or 0)
query_id = request.view_args.get('query_id', None)
if self.is_user_logged_in() and self.valid_user():
return fn(*args, **kwargs)
# TODO: 3600 should be a setting
if signature and query_id and time.time() < expires <= time.time() + 3600:
query = models.Query.get(models.Query.id == query_id)
calculated_signature = sign(query.api_key, request.path, expires)
if self.api_key_authentication():
return fn(*args, **kwargs)
if query.api_key and signature == calculated_signature:
return fn(*args, **kwargs)
# Work around for flask-restful testing only for flask.wrappers.Resource instead of
# werkzeug.wrappers.Response
resp = wrapped_fn(*args, **kwargs)
if isinstance(resp, werkzeug.wrappers.Response):
resp = make_response(resp)
return resp
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 decorated
def setup_authentication(app):
openid_auth = GoogleFederated(settings.GOOGLE_APPS_DOMAIN, 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
app.wsgi_app = ProxyFix(app.wsgi_app)
app.secret_key = settings.COOKIE_SECRET

View File

@@ -204,9 +204,8 @@ class QueryAPI(BaseResource):
if 'latest_query_data_id' in query_def:
query_def['latest_query_data'] = query_def.pop('latest_query_data_id')
update = models.Query.update(**query_def).where(models.Query.id == query_id)
update.execute()
models.Query.update_instance(query_id, **query_def)
query = models.Query.get_by_id(query_id)
return query.to_dict(with_result=False, with_visualizations=True)

View File

@@ -153,9 +153,12 @@ class Manager(object):
if self.workers:
return self.workers
if getattr(settings, 'CONNECTION_ADAPTER', None) == "mysql":
if connection_type == 'mysql':
from redash.data import query_runner_mysql
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)
else:
from redash.data import query_runner
runner = query_runner.redshift(connection_string)

View File

@@ -0,0 +1,46 @@
"""
QueryRunner for Graphite.
"""
import json
import datetime
import requests
from redash.utils import JSONEncoder
def graphite(connection_params):
def transform_result(response):
columns = [{'name': 'Time::x'}, {'name': 'value::y'}, {'name': 'name::series'}]
rows = []
for series in response.json():
for values in series['datapoints']:
timestamp = datetime.datetime.fromtimestamp(int(values[1]))
rows.append({'Time::x': timestamp, 'name::series': series['target'], 'value::y': values[0]})
data = {'columns': columns, 'rows': rows}
return json.dumps(data, cls=JSONEncoder)
def query_runner(query):
base_url = "%s/render?format=json&" % connection_params['url']
url = "%s%s" % (base_url, "&".join(query.split("\n")))
error = None
data = None
try:
response = requests.get(url, auth=connection_params['auth'],
verify=connection_params['verify'])
if response.status_code == 200:
data = transform_result(response)
else:
error = "Failed getting results (%d)" % response.status_code
except Exception, ex:
data = None
error = ex.message
return data, error
query_runner.annotate_query = False
return query_runner

View File

@@ -234,8 +234,11 @@ class Worker(threading.Thread):
start_time = time.time()
self.set_title("running query %s" % job_id)
annotated_query = "/* Pid: %s, Job Id: %s, Query hash: %s, Priority: %s */ %s" % \
(pid, job.id, job.query_hash, job.priority, job.query)
if getattr(self.query_runner, 'annotate_query', True):
annotated_query = "/* Pid: %s, Job Id: %s, Query hash: %s, Priority: %s */ %s" % \
(pid, job.id, job.query_hash, job.priority, job.query)
else:
annotated_query = job.query
# TODO: here's the part that needs to be forked, not all of the worker process...
data, error = self.query_runner(annotated_query)

View File

@@ -51,7 +51,7 @@ class Query(BaseModel):
id = peewee.PrimaryKeyField()
latest_query_data = peewee.ForeignKeyField(QueryResult, null=True)
name = peewee.CharField(max_length=255)
description = peewee.CharField(max_length=4096)
description = peewee.CharField(max_length=4096, null=True)
query = peewee.TextField()
query_hash = peewee.CharField(max_length=32)
api_key = peewee.CharField(max_length=40)
@@ -115,6 +115,14 @@ LEFT OUTER JOIN
"""
return cls.raw(query)
@classmethod
def update_instance(cls, query_id, **kwargs):
if 'query' in kwargs:
kwargs['query_hash'] = utils.gen_query_hash(kwargs['query'])
update = cls.update(**kwargs).where(cls.id == query_id)
return update.execute()
def save(self, *args, **kwargs):
self.query_hash = utils.gen_query_hash(self.query)
self._set_api_key()

58
redash/settings.py Normal file
View File

@@ -0,0 +1,58 @@
import os
import urlparse
def parse_db_url(url):
url_parts = urlparse.urlparse(url)
connection = {
'engine': 'peewee.PostgresqlDatabase',
}
if url_parts.hostname and not url_parts.path:
connection['name'] = url_parts.hostname
else:
connection['name'] = url_parts.path[1:]
connection['host'] = url_parts.hostname
connection['port'] = url_parts.port
connection['user'] = url_parts.username
connection['password'] = url_parts.password
return connection
def fix_assets_path(path):
fullpath = os.path.join(os.path.dirname(__file__), path)
return fullpath
def array_from_string(str):
array = str.split(',')
if "" in array:
array.remove("")
return array
REDIS_URL = os.environ.get('REDASH_REDIS_URL', "redis://localhost:6379")
# "pg", "graphite" or "mysql"
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}
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)
DATABASE_CONFIG = parse_db_url(os.environ.get("REDASH_DATABASE_URL", "postgresql://postgres"))
# 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", "")
# 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/"))
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")
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")

View File

@@ -1,29 +0,0 @@
"""
Example settings module. You should make your own copy as settings.py and enter the real settings.
"""
REDIS_URL = "redis://localhost:6379"
# Either "pg" or "mysql"
CONNECTION_ADAPTER = "pg"
# Connection string for the database that is used to run queries against
# -- example mysql CONNECTION_STRING = "Server=;User=;Pwd=;Database="
# -- example pg CONNECTION_STRING = "user= password= host= port=5439 dbname="
CONNECTION_STRING = "user= password= host= port=5439 dbname="
# Connection settings for re:dash's own database (where we store the queries, results, etc)
DATABASE_CONFIG = {
'name': 'postgres',
'engine': 'peewee.PostgresqlDatabase',
}
# Google Apps domain to allow access from; any user with email in this Google Apps will be allowed
# access
GOOGLE_APPS_DOMAIN = ""
# Email addresses of specific users not from the above set Google Apps Domain, that you want to
# allow access to re:dash
ALLOWED_USERS = []
# Email addresses of admin users
ADMINS = []
STATIC_ASSETS_PATH = "../rd_ui/dist/"
WORKERS_COUNT = 2
COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f"
LOG_LEVEL = "INFO"
ANALYTICS = ""

View File

@@ -23,3 +23,4 @@ sqlparse==0.1.8
wsgiref==0.1.2
wtf-peewee==0.2.2
Flask-Script==0.6.6
honcho==0.5.0

View File

@@ -1,15 +0,0 @@
from tests import BaseTestCase
from factories import dashboard_factory
class DashboardTest(BaseTestCase):
def test_appends_suffix_to_slug_when_duplicate(self):
d1 = dashboard_factory.create()
self.assertEquals(d1.slug, 'test')
d2 = dashboard_factory.create()
self.assertNotEquals(d1.slug, d2.slug)
d3 = dashboard_factory.create()
self.assertNotEquals(d1.slug, d3.slug)
self.assertNotEquals(d2.slug, d3.slug)

View File

@@ -1,14 +1,17 @@
from contextlib import contextmanager
import json
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
from redash import app, models
from redash import app, models, settings
from redash.utils import json_dumps
from redash.authentication import sign
settings.GOOGLE_APPS_DOMAIN = "example.com"
@contextmanager
def authenticated_user(c, user='test@example.com', name='John Test'):
with c.session_transaction() as sess:
@@ -45,7 +48,42 @@ class AuthenticationTestMixin():
self.assertEquals(200, rv.status_code)
class PingTest(BaseTestCase):
class TestAuthentication(TestCase):
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):
with app.test_client() as c:
rv = c.get('/ping')
@@ -83,7 +121,7 @@ class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.status_code, 404)
def test_create_new_dashboard(self):
user_email = 'test@everything.me'
user_email = 'test@example.com'
with app.test_client() as c, authenticated_user(c, user=user_email):
dashboard_name = 'Test Dashboard'
rv = json_request(c.post, '/api/dashboards', data={'name': dashboard_name})
@@ -182,10 +220,9 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
self.assertEquals(rv.json['name'], 'Testing')
def test_create_query(self):
user = 'test@everything.me'
user = 'test@example.com'
query_data = {
'name': 'Testing',
'description': 'Description',
'query': 'SELECT 1',
'ttl': 3600
}

28
tests/test_models.py Normal file
View File

@@ -0,0 +1,28 @@
from tests import BaseTestCase
from redash import models
from factories import dashboard_factory, query_factory
class DashboardTest(BaseTestCase):
def test_appends_suffix_to_slug_when_duplicate(self):
d1 = dashboard_factory.create()
self.assertEquals(d1.slug, 'test')
d2 = dashboard_factory.create()
self.assertNotEquals(d1.slug, d2.slug)
d3 = dashboard_factory.create()
self.assertNotEquals(d1.slug, d3.slug)
self.assertNotEquals(d2.slug, d3.slug)
class QueryTest(BaseTestCase):
def test_changing_query_text_changes_hash(self):
q = query_factory.create()
old_hash = q.query_hash
models.Query.update_instance(q.id, query="SELECT 2;")
q = models.Query.get_by_id(q.id)
self.assertNotEquals(old_hash, q.query_hash)

27
tests/test_settings.py Normal file
View File

@@ -0,0 +1,27 @@
from redash import settings as settings
from unittest import TestCase
class TestDatabaseUrlParser(TestCase):
def test_only_database_name(self):
config = settings.parse_db_url("postgresql://postgres")
self.assertEquals(config['name'], 'postgres')
def test_host_and_database_name(self):
config = settings.parse_db_url("postgresql://localhost/postgres")
self.assertEquals(config['name'], 'postgres')
self.assertEquals(config['host'], 'localhost')
def test_host_with_port_and_database_name(self):
config = settings.parse_db_url("postgresql://localhost:5432/postgres")
self.assertEquals(config['name'], 'postgres')
self.assertEquals(config['host'], 'localhost')
self.assertEquals(config['port'], 5432)
def test_full_url(self):
config = settings.parse_db_url("postgresql://user:pass@localhost:5432/postgres")
self.assertEquals(config['name'], 'postgres')
self.assertEquals(config['host'], 'localhost')
self.assertEquals(config['port'], 5432)
self.assertEquals(config['user'], 'user')
self.assertEquals(config['password'], 'pass')