mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
82 Commits
v0.3.1+b85
...
v0.3.3+b15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f98f7405 | ||
|
|
ac946fd014 | ||
|
|
3680d0c65d | ||
|
|
9cac38d5da | ||
|
|
81122c9865 | ||
|
|
b8a0077b1d | ||
|
|
62108e3dac | ||
|
|
0c9fa8b51b | ||
|
|
aa2bf4fe22 | ||
|
|
e82f561c03 | ||
|
|
d348fe9012 | ||
|
|
7271b7a5f0 | ||
|
|
522536cfe0 | ||
|
|
f557b53ce2 | ||
|
|
1277da7e92 | ||
|
|
f334122e41 | ||
|
|
269cbe839b | ||
|
|
2a3bcc2ecb | ||
|
|
5babab85c8 | ||
|
|
8debd01a36 | ||
|
|
51a37cae3d | ||
|
|
3cbdae6e5c | ||
|
|
edcf0661a6 | ||
|
|
6d14c5c555 | ||
|
|
a0662d5323 | ||
|
|
cbd1cf7c25 | ||
|
|
a55225b5e8 | ||
|
|
b81c3ba614 | ||
|
|
2d0998a995 | ||
|
|
766840de68 | ||
|
|
791f2e0b34 | ||
|
|
9241a7c35d | ||
|
|
dda92477cf | ||
|
|
07455e5821 | ||
|
|
1b9aae0137 | ||
|
|
30b86ea781 | ||
|
|
a186d44d8f | ||
|
|
574f75b293 | ||
|
|
252ae7455a | ||
|
|
72065c0ee2 | ||
|
|
07caee1d12 | ||
|
|
4c3904760c | ||
|
|
8ad2c2a59e | ||
|
|
e5a365ba41 | ||
|
|
fc0b118188 | ||
|
|
a207b93d0d | ||
|
|
b1d588b1f2 | ||
|
|
95a6bab8b5 | ||
|
|
c82433e6b4 | ||
|
|
2e84852519 | ||
|
|
da746d15a0 | ||
|
|
1b519269d8 | ||
|
|
5ffaf1aead | ||
|
|
b704406164 | ||
|
|
5c9fe40702 | ||
|
|
fe7c4f96aa | ||
|
|
83909a07fa | ||
|
|
cd99927881 | ||
|
|
8bbb485d5b | ||
|
|
b2ec77668e | ||
|
|
f8302ab65a | ||
|
|
e632cf1c42 | ||
|
|
640557df4f | ||
|
|
9b7227a88b | ||
|
|
aabc912862 | ||
|
|
02d6567347 | ||
|
|
6f8767d1fc | ||
|
|
bc787efc86 | ||
|
|
e0d46c3942 | ||
|
|
5a2bed29aa | ||
|
|
8fbcd0c34d | ||
|
|
97df37536c | ||
|
|
373b9c6a97 | ||
|
|
009726c62d | ||
|
|
69c07a41e9 | ||
|
|
64afd62a1f | ||
|
|
4318468957 | ||
|
|
1af3fc1c96 | ||
|
|
1e11f8032a | ||
|
|
a1a7ca8a0a | ||
|
|
52758fa66e | ||
|
|
fa43ff1365 |
9
.env.example
Normal file
9
.env.example
Normal 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
2
.gitignore
vendored
@@ -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,3 +0,0 @@
|
||||
cookbook 'apt'
|
||||
cookbook 'postgresql'
|
||||
cookbook 'redash', git: 'git@github.com:EverythingMe/chef-redash.git'
|
||||
2
Procfile.dev
Normal file
2
Procfile.dev
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT
|
||||
worker: ./manage.py runworkers
|
||||
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
|
||||
@@ -8,9 +8,8 @@ machine:
|
||||
dependencies:
|
||||
pre:
|
||||
- make deps
|
||||
- pip install requests coverage nose
|
||||
- pip install -r dev_requirements.txt
|
||||
- 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:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
nose==1.3.0
|
||||
coverage==3.7.1
|
||||
mock==1.0.1
|
||||
|
||||
35
manage.py
35
manage.py
@@ -10,16 +10,18 @@ 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():
|
||||
"""Displays re:dash version."""
|
||||
print __version__
|
||||
|
||||
|
||||
@manager.command
|
||||
def runworkers():
|
||||
"""Starts the re:dash query executors/workers."""
|
||||
@@ -62,7 +64,38 @@ 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()
|
||||
logging.getLogger().addHandler(channel)
|
||||
logging.getLogger().setLevel(settings.LOG_LEVEL)
|
||||
|
||||
manager.run()
|
||||
12
migrations/add_password_to_users.py
Normal file
12
migrations/add_password_to_users.py
Normal 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)
|
||||
12
migrations/change_queries_description_to_nullable.py
Normal file
12
migrations/change_queries_description_to_nullable.py
Normal 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)
|
||||
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)
|
||||
@@ -4,7 +4,7 @@ from redash import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_options = {"series": {"type": "bar"}}
|
||||
default_options = {"series": {"type": "column"}}
|
||||
|
||||
db.connect_db()
|
||||
|
||||
|
||||
@@ -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 %>'
|
||||
}
|
||||
|
||||
@@ -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
85
rd_ui/app/login.html
Normal 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>
|
||||
@@ -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);
|
||||
@@ -112,6 +151,9 @@
|
||||
} else {
|
||||
// TODO: replace this with a safer method
|
||||
$location.path($location.path().replace(oldId, q.id)).replace();
|
||||
|
||||
// Reset visualizations tab to table after duplicating a query:
|
||||
$location.hash('table');
|
||||
}
|
||||
}
|
||||
}, function(httpResponse) {
|
||||
@@ -139,6 +181,7 @@
|
||||
|
||||
$scope.refreshOptions = [
|
||||
{value: -1, name: 'No Refresh'},
|
||||
{value: 60, name: 'Every minute'},
|
||||
]
|
||||
|
||||
_.each(_.range(1, 13), function(i) {
|
||||
@@ -196,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);
|
||||
}
|
||||
|
||||
@@ -260,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';
|
||||
@@ -287,7 +330,7 @@
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user'
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
@@ -386,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])
|
||||
|
||||
@@ -62,11 +62,21 @@
|
||||
'Cohort': Visualization.prototype.TYPES.COHORT
|
||||
};
|
||||
scope.seriesTypes = {
|
||||
'Line': Visualization.prototype.SERIES_TYPES.LINE,
|
||||
'Bar': Visualization.prototype.SERIES_TYPES.BAR,
|
||||
'Area': Visualization.prototype.SERIES_TYPES.AREA
|
||||
'Line': 'line',
|
||||
'Column': 'column',
|
||||
'Area': 'area',
|
||||
'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
|
||||
@@ -77,33 +87,57 @@
|
||||
'query_id': q.id,
|
||||
'type': Visualization.prototype.TYPES.CHART,
|
||||
'name': '',
|
||||
'description': q.description,
|
||||
'options': newOptions()
|
||||
'description': q.description || '',
|
||||
'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': Visualization.prototype.SERIES_TYPES.LINE
|
||||
}
|
||||
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() {
|
||||
@@ -111,7 +145,7 @@
|
||||
};
|
||||
|
||||
scope.typeChanged = function() {
|
||||
scope.vis.options = newOptions();
|
||||
scope.vis.options = newOptions(scope.vis.type);
|
||||
};
|
||||
|
||||
scope.submit = function() {
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
(function(){
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var defaultOptions = {
|
||||
title: {
|
||||
"text": null
|
||||
},
|
||||
tooltip: {
|
||||
valueDecimals: 2,
|
||||
formatter: function () {
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
|
||||
if (pointsCount > 1 && point.percentage) {
|
||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||
}
|
||||
});
|
||||
} 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);
|
||||
});
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
shared: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime'
|
||||
},
|
||||
@@ -40,6 +13,47 @@
|
||||
text: null
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
valueDecimals: 2,
|
||||
formatter: function () {
|
||||
if (!this.points) {
|
||||
this.points = [this.point];
|
||||
};
|
||||
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
|
||||
if (pointsCount > 1 && point.percentage) {
|
||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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) + '%)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
shared: true
|
||||
},
|
||||
exporting: {
|
||||
chartOptions: {
|
||||
title: {
|
||||
@@ -70,12 +84,63 @@
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
"column": {
|
||||
"stacking": "normal",
|
||||
"pointPadding": 0,
|
||||
"borderWidth": 1,
|
||||
"groupPadding": 0,
|
||||
"shadow": false
|
||||
area: {
|
||||
marker: {
|
||||
enabled: false,
|
||||
symbol: 'circle',
|
||||
radius: 2,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
column: {
|
||||
stacking: "normal",
|
||||
pointPadding: 0,
|
||||
borderWidth: 1,
|
||||
groupPadding: 0,
|
||||
shadow: false
|
||||
},
|
||||
line: {
|
||||
marker: {
|
||||
radius: 1
|
||||
},
|
||||
lineWidth: 2,
|
||||
states: {
|
||||
hover: {
|
||||
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,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true,
|
||||
lineColor: 'rgb(100,100,100)'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
headerFormat: '<b>{series.name}</b><br>',
|
||||
pointFormat: '{point.x}, {point.y}'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: []
|
||||
@@ -105,26 +170,34 @@
|
||||
|
||||
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||
|
||||
// Update when options change
|
||||
scope.$watch('options', function(newOptions) {
|
||||
initChart(newOptions);
|
||||
}, true);
|
||||
// $timeout makes sure that this function invoked after the DOM ready. When draw/init
|
||||
// invoked after the DOM is ready, we see first an empty HighCharts objects and later
|
||||
// they get filled up. Which gives the feeling that the charts loading faster (otherwise
|
||||
// we stare at an empty screen until the HighCharts object is ready).
|
||||
$timeout(function(){
|
||||
// Update when options change
|
||||
scope.$watch('options', function (newOptions) {
|
||||
initChart(newOptions);
|
||||
}, true);
|
||||
|
||||
//Update when charts data changes
|
||||
scope.$watch(function () {
|
||||
return (scope.series && scope.series.length) || 0;
|
||||
}, function (length) {
|
||||
if (!length || length == 0) {
|
||||
scope.chart.showLoading();
|
||||
} else {
|
||||
drawChart();
|
||||
};
|
||||
}, true);
|
||||
//Update when charts data changes
|
||||
scope.$watch(function () {
|
||||
// TODO: this might be an issue in case the series change, but they stay
|
||||
// with the same length
|
||||
return (scope.series && scope.series.length) || 0;
|
||||
}, function (length) {
|
||||
if (!length || length == 0) {
|
||||
scope.chart.showLoading();
|
||||
} else {
|
||||
drawChart();
|
||||
};
|
||||
}, true);
|
||||
});
|
||||
|
||||
function initChart(options) {
|
||||
if (scope.chart) {
|
||||
scope.chart.destroy();
|
||||
}
|
||||
scope.chart.destroy();
|
||||
};
|
||||
|
||||
$.extend(true, chartOptions, options);
|
||||
|
||||
@@ -133,42 +206,63 @@
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
while(scope.chart.series.length > 0) {
|
||||
scope.chart.series[0].remove(true);
|
||||
}
|
||||
while (scope.chart.series.length > 0) {
|
||||
scope.chart.series[0].remove(false);
|
||||
};
|
||||
|
||||
// todo series.type
|
||||
|
||||
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
|
||||
if (_.some(scope.series[0].data, function (p) {
|
||||
return (angular.isString(p.x) || angular.isDefined(p.name));
|
||||
})) {
|
||||
scope.chart.xAxis[0].update({type: 'category'});
|
||||
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
|
||||
if (!angular.isDefined(scope.series[0].data[0].name)) {
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(scope.series, function (s) {
|
||||
return _.pluck(s.data, 'x')
|
||||
}));
|
||||
|
||||
_.each(scope.series, function(s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
_.each(scope.series, function (s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
|
||||
var newData = _.sortBy(_.map(categories, function(category) {
|
||||
return {
|
||||
name: category,
|
||||
y: yValues[category] && yValues[category][0].y
|
||||
}
|
||||
}), 'name');
|
||||
var newData = _.sortBy(_.map(categories, function (category) {
|
||||
return {
|
||||
name: category,
|
||||
y: yValues[category] && yValues[category][0].y
|
||||
}
|
||||
}), 'y').reverse();
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
s.data = newData;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
scope.chart.xAxis[0].update({type: 'datetime'});
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(scope.series, function(s) {
|
||||
_.each(scope.series, function (s) {
|
||||
// here we override the series with the visualization config
|
||||
var _s = $.extend(true, {}, s, chartOptions['series']);
|
||||
scope.chart.addSeries(_s);
|
||||
})
|
||||
s = _.extend(s, chartOptions['series']);
|
||||
|
||||
if (s.type == 'area') {
|
||||
_.each(s.data, function (p) {
|
||||
// This is an insane hack: somewhere deep in HighChart's code,
|
||||
// when you stack areas, it tries to convert the string representation
|
||||
// of point's x into a number. With the default implementation of toString
|
||||
// it fails....
|
||||
|
||||
if (moment.isMoment(p.x)) {
|
||||
p.x.toString = function () {
|
||||
return String(this.toDate().getTime());
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
scope.chart.addSeries(s, false);
|
||||
});
|
||||
|
||||
scope.chart.redraw();
|
||||
scope.chart.hideLoading();
|
||||
|
||||
@@ -291,11 +291,6 @@
|
||||
'CHART': 'CHART',
|
||||
'COHORT': 'COHORT',
|
||||
'TABLE': 'TABLE'
|
||||
},
|
||||
SERIES_TYPES: {
|
||||
'LINE': 'line',
|
||||
'BAR': 'bar',
|
||||
'AREA': 'area'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
37
rd_ui/app/styles/login.css
Normal file
37
rd_ui/app/styles/login.css
Normal 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%;*/
|
||||
/*}*/
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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)"> ×</span>
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="currentUser.canEdit(query)"> ×</span>
|
||||
</rd-tab>
|
||||
<rd-tab id="add" name="+New" removeable="true"></rd-tab>
|
||||
<rd-tab id="add" name="+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>
|
||||
|
||||
@@ -7,14 +7,13 @@ from flask_peewee.db import Database
|
||||
import redis
|
||||
from redash import settings, utils
|
||||
|
||||
__version__ = '0.3.1'
|
||||
__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
|
||||
@@ -33,9 +32,13 @@ 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)
|
||||
|
||||
from redash import controllers
|
||||
from redash import controllers
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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, login
|
||||
from flask.ext.login import LoginManager, login_user, current_user
|
||||
import time
|
||||
import logging
|
||||
from werkzeug.contrib.fixers import ProxyFix
|
||||
import werkzeug.wrappers
|
||||
from redash import models, settings
|
||||
|
||||
login_manager = LoginManager()
|
||||
logger = logging.getLogger('authentication')
|
||||
|
||||
def sign(key, path, expires):
|
||||
if not key:
|
||||
@@ -20,40 +23,82 @@ 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')
|
||||
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 current_user.is_authenticated()
|
||||
|
||||
def required(self, fn):
|
||||
wrapped_fn = self.auth.required(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():
|
||||
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
|
||||
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 = GoogleFederated(settings.GOOGLE_APPS_DOMAIN, app)
|
||||
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()
|
||||
|
||||
@@ -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,18 +236,17 @@ 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:
|
||||
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)
|
||||
return query.to_dict(with_result=False, with_visualizations=True)
|
||||
|
||||
def get(self, query_id):
|
||||
q = models.Query.get(models.Query.id == query_id)
|
||||
@@ -327,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)
|
||||
|
||||
|
||||
@@ -153,9 +153,17 @@ 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
|
||||
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)
|
||||
|
||||
46
redash/data/query_runner_graphite.py
Normal file
46
redash/data/query_runner_graphite.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
111
redash/models.py
111
redash/models.py
@@ -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)
|
||||
@@ -51,12 +72,13 @@ 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)
|
||||
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:
|
||||
@@ -64,11 +86,11 @@ class Query(BaseModel):
|
||||
|
||||
def create_default_visualizations(self):
|
||||
table_visualization = Visualization(query=self, name="Table",
|
||||
description=self.description,
|
||||
description='',
|
||||
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,25 @@ 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):
|
||||
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)
|
||||
@@ -123,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)
|
||||
@@ -145,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:
|
||||
@@ -156,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:
|
||||
@@ -185,7 +222,7 @@ class Visualization(BaseModel):
|
||||
type = peewee.CharField(max_length=100)
|
||||
query = peewee.ForeignKeyField(Query, related_name='visualizations')
|
||||
name = peewee.CharField(max_length=255)
|
||||
description = peewee.CharField(max_length=4096)
|
||||
description = peewee.CharField(max_length=4096, null=True)
|
||||
options = peewee.TextField()
|
||||
|
||||
class Meta:
|
||||
@@ -237,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):
|
||||
|
||||
66
redash/settings.py
Normal file
66
redash/settings.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
def parse_boolean(str):
|
||||
return json.loads(str.lower())
|
||||
|
||||
|
||||
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", "")
|
||||
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/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")
|
||||
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -23,3 +25,4 @@ sqlparse==0.1.8
|
||||
wsgiref==0.1.2
|
||||
wtf-peewee==0.2.2
|
||||
Flask-Script==0.6.6
|
||||
honcho==0.5.0
|
||||
|
||||
@@ -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":[]}',
|
||||
|
||||
@@ -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)
|
||||
66
tests/test_authentication.py
Normal file
66
tests/test_authentication.py
Normal 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)
|
||||
@@ -1,18 +1,27 @@
|
||||
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
|
||||
from redash import app, models
|
||||
query_result_factory, user_factory
|
||||
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'):
|
||||
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
|
||||
|
||||
@@ -45,7 +54,14 @@ class AuthenticationTestMixin():
|
||||
self.assertEquals(200, rv.status_code)
|
||||
|
||||
|
||||
class PingTest(BaseTestCase):
|
||||
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)
|
||||
|
||||
|
||||
class PingTest(TestCase):
|
||||
def test_ping(self):
|
||||
with app.test_client() as c:
|
||||
rv = c.get('/ping')
|
||||
@@ -83,13 +99,13 @@ class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
|
||||
self.assertEquals(rv.status_code, 404)
|
||||
|
||||
def test_create_new_dashboard(self):
|
||||
user_email = 'test@everything.me'
|
||||
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):
|
||||
@@ -182,10 +198,9 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
|
||||
self.assertEquals(rv.json['name'], 'Testing')
|
||||
|
||||
def test_create_query(self):
|
||||
user = 'test@everything.me'
|
||||
user = user_factory.create()
|
||||
query_data = {
|
||||
'name': 'Testing',
|
||||
'description': 'Description',
|
||||
'query': 'SELECT 1',
|
||||
'ttl': 3600
|
||||
}
|
||||
@@ -195,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'])
|
||||
|
||||
@@ -321,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())
|
||||
28
tests/test_models.py
Normal file
28
tests/test_models.py
Normal 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(user=d1.user)
|
||||
self.assertNotEquals(d1.slug, d2.slug)
|
||||
|
||||
d3 = dashboard_factory.create(user=d1.user)
|
||||
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
27
tests/test_settings.py
Normal 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')
|
||||
Reference in New Issue
Block a user