Merge pull request #84 from EverythingMe/refactor_flask

Big refactor: flask, peewee, tests, structure changes and more
This commit is contained in:
Arik Fraimovich
2014-02-09 19:11:59 +02:00
36 changed files with 1240 additions and 741 deletions

5
.coveragerc Normal file
View File

@@ -0,0 +1,5 @@
[report]
omit =
*/settings.py
*/python?.?/*
*/site-packages/nose/*

6
.gitignore vendored
View File

@@ -1,10 +1,12 @@
.coveralls.yml
.idea
*.pyc
rd_service/settings.py
.coverage
redash/settings.py
rd_ui/dist
.DS_Store
# Vagrant related
.vagrant
Berksfile.lock
rd_service/dump.rdb
redash/dump.rdb

View File

@@ -1,6 +1,6 @@
NAME=redash
VERSION=0.2
FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM)
VERSION=`python ./manage.py version`
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz
deps:
@@ -14,3 +14,6 @@ pack:
upload:
python bin/upload_version.py $(FULL_VERSION) $(FILENAME)
test:
nosetests --with-coverage --cover-package=redash tests/*.py

View File

@@ -1,4 +1,5 @@
# [_re:dash_](https://github.com/everythingme/redash)
![Build Status](https://circleci.com/gh/EverythingMe/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040 "Build Status")
**_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
@@ -28,8 +29,8 @@ Due to Heroku dev plan limits, it has a small database of flights (see schema [h
## Technology
* Python
* [AngularJS](http://angularjs.org/)
* [Tornado](http://tornadoweb.org)
* [PostgreSQL](http://www.postgresql.org/) / [AWS Redshift](http://aws.amazon.com/redshift/)
* [Redis](http://redis.io)
@@ -45,62 +46,33 @@ It's very likely that in the future we will switch to [D3.js](http://d3js.org/)
## Getting Started
1. Clone the repo:
```bash
git clone git@github.com:EverythingMe/redash.git
```
2. Create settings file from the example one (& update relevant settings):
```bash
cp rd_service/settings_example.py rd_service/settings.py
```
It's highly recommended that the user you use to connect to the data database (the one you query) is read-only.
3. Create the operational databases from rd_service/data/tables.sql
3. Install `npm` packages (mainly: Bower & Grunt):
```bash
cd rd_ui
npm install
```
4. Install `bower` packages:
```bash
bower install
```
5. Build the UI:
```bash
grunt build
```
6. Install PIP packages:
```bash
pip install -r ../rd_service/requirements.txt
```
6. Start the API server:
```bash
cd ../rd_service
python server.py
```
7. Start the workers:
```bash
python cli.py worker
```
8. Open `http://localhost:8888/` and query away.
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.
**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.
## Roadmap
We plan to release new minor version every 2-3 weeks. Of course, if we get additional help from contributors it will help speed things up.
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.
- Dashboard filters: ability to filter/slice the data you see in a single dashboard using filters (date or selectors).
- UI Improvements (better notifications & flows, improved queries page)
- Comments on queries.
- 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)
- Support for API access using API keys, instead of Google Login.
- Dashboard filters: ability to filter/slice the data you see in a single dashboard using filters (date or selectors).
- Multiple databases support (including other database type than PostgreSQL).
- Scheduled reports by email.
- Comments on queries.
### [v0.4](https://github.com/EverythingMe/redash/issues?milestone=3&state=open)

View File

@@ -15,7 +15,8 @@ if __name__ == '__main__':
params = json.dumps({
'tag_name': 'v{0}'.format(version),
'name': 're:dash v{0}'.format(version),
'target_commitish': commit_sha
'target_commitish': commit_sha,
'prerelease': True
})
response = requests.post('https://api.github.com/repos/everythingme/redash/releases',

View File

@@ -8,9 +8,17 @@ machine:
dependencies:
pre:
- make deps
- pip install requests
- 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/
test:
override:
- make test
post:
- rm redash/settings.py
- make pack
deployment:
github:

2
dev_requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
nose==1.3.0
coverage==3.7.1

68
manage.py Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python
"""
CLI to manage redash.
"""
import atfork
atfork.monkeypatch_os_fork_functions()
import atfork.stdlib_fixer
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
manager = Manager(app)
database_manager = Manager(help="Manages the database (create/drop tables).")
@manager.command
def version():
"""Displays re:dash version."""
print __version__
@manager.command
def runworkers():
"""Starts the re:dash query executors/workers."""
try:
old_workers = data_manager.redis_connection.smembers('workers')
data_manager.redis_connection.delete('workers')
logging.info("Cleaning old workers: %s", old_workers)
data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_ADAPTER, settings.CONNECTION_STRING)
logging.info("Workers started.")
while True:
try:
data_manager.refresh_queries()
except Exception as e:
logging.error("Something went wrong with refreshing queries...")
logging.exception(e)
time.sleep(60)
except KeyboardInterrupt:
logging.warning("Exiting; waiting for threads")
data_manager.stop_workers()
@manager.shell
def make_shell_context():
return dict(app=app, db=db, models=models)
@database_manager.command
def create_tables():
"""Creates the database tables."""
from redash.models import create_db
create_db(True, False)
@database_manager.command
def drop_tables():
"""Drop the database tables."""
from redash.models import create_db
create_db(False, True)
manager.add_command("database", database_manager)
if __name__ == '__main__':
manager.run()

View File

@@ -0,0 +1,13 @@
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.Dashboard, models.Dashboard.created_at, 'created_at')
migrator.add_column(models.Widget, models.Widget.created_at, 'created_at')
db.close_db(None)

View File

@@ -0,0 +1,13 @@
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.Widget, models.Widget.query_id, True)
migrator.set_nullable(models.Widget, models.Widget.type, True)
db.close_db(None)

View File

@@ -0,0 +1,70 @@
import json
from playhouse.migrate import Migrator
from redash import db
from redash import models
if __name__ == '__main__':
default_options = {"series": {"type": "bar"}}
db.connect_db()
if not models.Visualization.table_exists():
print "Creating visualization table..."
models.Visualization.create_table()
with db.database.transaction():
migrator = Migrator(db.database)
print "Adding visualization_id to widgets:"
field = models.Widget.visualization
field.null = True
migrator.add_column(models.Widget, models.Widget.visualization, 'visualization_id')
print 'Creating TABLE visualizations for all queries...'
for query in models.Query.select():
vis = models.Visualization(query=query, name="Table",
description=query.description or "",
type="TABLE", options="{}")
vis.save()
print 'Creating COHORT visualizations for all queries named like %cohort%...'
for query in models.Query.select().where(models.Query.name ** "%cohort%"):
vis = models.Visualization(query=query, name="Cohort",
description=query.description or "",
type="COHORT", options="{}")
vis.save()
print 'Create visualization for all widgets (unless exists already):'
for widget in models.Widget.select():
print 'Processing widget id: %d:' % widget.id
vis_type = widget.type.upper()
if vis_type == 'GRID':
vis_type = 'TABLE'
query = models.Query.get_by_id(widget.query_id)
vis = query.visualizations.where(models.Visualization.type == vis_type).first()
if vis:
print '... visualization type (%s) found.' % vis_type
widget.visualization = vis
widget.save()
else:
vis_name = vis_type.title()
options = json.loads(widget.options)
vis_options = {"series": options} if options else default_options
vis_options = json.dumps(vis_options)
vis = models.Visualization(query=query, name=vis_name,
description=query.description or "",
type=vis_type, options=vis_options)
print '... Created visualization for type: %s' % vis_type
vis.save()
widget.visualization = vis
widget.save()
with db.database.transaction():
migrator = Migrator(db.database)
print "Setting visualization_id as not null..."
migrator.set_nullable(models.Widget, models.Widget.visualization, False)
db.close_db(None)

View File

@@ -1 +0,0 @@

View File

@@ -1,59 +0,0 @@
"""
CLI to start the workers.
TODO: move API server startup here.
"""
import atfork
atfork.monkeypatch_os_fork_functions()
import atfork.stdlib_fixer
atfork.stdlib_fixer.fix_logging_module()
import argparse
import logging
import urlparse
import redis
import time
import settings
import data
def start_workers(data_manager):
try:
old_workers = data_manager.redis_connection.smembers('workers')
data_manager.redis_connection.delete('workers')
logging.info("Cleaning old workers: %s", old_workers)
data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_STRING)
logging.info("Workers started.")
while True:
try:
data_manager.refresh_queries()
except Exception as e:
logging.error("Something went wrong with refreshing queries...")
logging.exception(e)
time.sleep(60)
except KeyboardInterrupt:
logging.warning("Exiting; waiting for threads")
data_manager.stop_workers()
if __name__ == '__main__':
channel = logging.StreamHandler()
logging.getLogger().addHandler(channel)
logging.getLogger().setLevel(settings.LOG_LEVEL)
parser = argparse.ArgumentParser()
parser.add_argument("command")
args = parser.parse_args()
url = urlparse.urlparse(settings.REDIS_URL)
redis_connection = redis.StrictRedis(host=url.hostname, port=url.port, db=0, password=url.password)
data_manager = data.Manager(redis_connection, settings.INTERNAL_DB_CONNECTION_STRING, settings.MAX_CONNECTIONS)
if args.command == "worker":
start_workers(data_manager)
else:
print "Unknown command"

View File

@@ -1,56 +0,0 @@
BEGIN;
CREATE TABLE "query_results" (
"id" serial NOT NULL PRIMARY KEY,
"query_hash" varchar(32) NOT NULL,
"query" text NOT NULL,
"data" text NOT NULL,
"runtime" double precision NOT NULL,
"retrieved_at" timestamp with time zone NOT NULL
)
;
CREATE TABLE "queries" (
"id" serial NOT NULL PRIMARY KEY,
"latest_query_data_id" integer REFERENCES "query_results" ("id") DEFERRABLE INITIALLY DEFERRED,
"name" varchar(255) NOT NULL,
"description" varchar(4096),
"query" text NOT NULL,
"query_hash" varchar(32) NOT NULL,
"api_key" varchar(40),
"ttl" integer NOT NULL,
"user" varchar(360) NOT NULL,
"created_at" timestamp with time zone NOT NULL
)
;
CREATE TABLE "dashboards" (
"id" serial NOT NULL PRIMARY KEY,
"slug" varchar(140) NOT NULL,
"name" varchar(100) NOT NULL,
"user" varchar(360) NOT NULL,
"layout" text NOT NULL,
"is_archived" boolean NOT NULL
)
;
CREATE TABLE "visualizations" (
"id" serial NOT NULL PRIMARY KEY,
"type" varchar(100) NOT NULL,
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
"name" varchar(255) NOT NULL,
"description" varchar(4096),
"options" text NOT NULL
)
;
CREATE TABLE "widgets" (
"id" serial NOT NULL PRIMARY KEY,
"type" varchar(100) NOT NULL,
"width" integer NOT NULL,
"options" text NOT NULL,
"query_id" integer,
"visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED,
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
)
;
CREATE INDEX "queries_latest_query_data_id" ON "queries" ("latest_query_data_id");
CREATE INDEX "widgets_query_id" ON "widgets" ("query_id");
CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id");
COMMIT;

View File

@@ -1,67 +0,0 @@
import json
import settings
from data.models import *
# first run:
# CREATE TABLE "visualizations" (
# "id" serial NOT NULL PRIMARY KEY,
# "type" varchar(100) NOT NULL,
# "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
# "name" varchar(255) NOT NULL,
# "description" varchar(4096),
# "options" text NOT NULL
# )
# ;
# ALTER TABLE widgets ADD COLUMN "visualization_id" integer REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED;
if __name__ == '__main__':
default_options = {"series": {"type": "bar"}}
# create 'table' visualization for all queries
print 'creating TABLE visualizations ...'
for query in Query.objects.all():
vis = Visualization(query=query, name="Table",
description=query.description,
type="TABLE", options="{}")
vis.save()
# create 'cohort' visualization for all queries named with 'cohort'
print 'creating COHORT visualizations ...'
for query in Query.objects.filter(name__icontains="cohort"):
vis = Visualization(query=query, name="Cohort",
description=query.description,
type="COHORT", options="{}")
vis.save()
# create visualization for every widget (unless it already exists)
print 'migrating Widgets -> Visualizations ...'
for widget in Widget.objects.all():
print 'processing widget %d:' % widget.id,
query = widget.query
vis_type = widget.type.upper()
vis = query.visualizations.filter(type=vis_type)
if vis:
print 'visualization exists'
widget.visualization = vis[0]
widget.save()
else:
vis_name = widget.type.title()
options = json.loads(widget.options)
vis_options = {"series": options} if options else default_options
vis_options = json.dumps(vis_options)
vis = Visualization(query=query, name=vis_name,
description=query.description,
type=vis_type, options=vis_options)
print 'created visualization %s' % vis_type
vis.save()
widget.visualization = vis
widget.save()

View File

@@ -1,10 +0,0 @@
psycopg2==2.5.1
redis==2.7.5
tornado==3.0.2
sqlparse==0.1.8
Django==1.5.4
django-db-pool==0.0.10
qr==0.6.0
python-dateutil==2.1
setproctitle==1.1.8
atfork==0.1.2

View File

@@ -1,382 +0,0 @@
"""
Tornado based API implementation for re:dash.
Also at the moment the Tornado server is used to serve the static assets (and the Angular.js app),
but this is only due to configuration issues and temporary.
Usage:
python server.py [--port=8888] [--debug] [--static=..]
port - port to listen to
debug - enable debug mode (extensive logging, restart on code change)
static - static assets path
If static option isn't specified it will be taken from settings.py.
"""
import csv
import hashlib
import json
import numbers
import os
import urlparse
import logging
import cStringIO
import datetime
import dateutil.parser
import redis
import sqlparse
import tornado.ioloop
import tornado.web
import tornado.auth
import tornado.options
import settings
import time
from data import utils
import data
class BaseHandler(tornado.web.RequestHandler):
def initialize(self):
self.data_manager = self.application.settings.get('data_manager', None)
self.redis_connection = self.application.settings['redis_connection']
def get_current_user(self):
user = self.get_secure_cookie("user")
return user
def write_json(self, response, encode=True):
if encode:
response = json.dumps(response, cls=utils.JSONEncoder)
self.set_header("Content-Type", "application/json; charset=UTF-8")
self.write(response)
class BaseAuthenticatedHandler(BaseHandler):
@tornado.web.authenticated
def prepare(self):
pass
class PingHandler(tornado.web.RequestHandler):
def get(self):
self.write("PONG")
class GoogleLoginHandler(tornado.web.RequestHandler,
tornado.auth.GoogleMixin):
@tornado.web.asynchronous
@tornado.gen.coroutine
def get(self):
if self.get_argument("openid.mode", None):
user = yield self.get_authenticated_user()
if user['email'] in settings.ALLOWED_USERS or user['email'].endswith("@%s" % settings.GOOGLE_APPS_DOMAIN):
logging.info("Authenticated: %s", user['email'])
self.set_secure_cookie("user", user['email'])
self.redirect("/")
else:
logging.error("Failed logging in with: %s", user)
self.authenticate_redirect()
else:
self.authenticate_redirect()
class MainHandler(BaseAuthenticatedHandler):
def get(self, *args):
email_md5 = hashlib.md5(self.current_user.lower()).hexdigest()
gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5
user = {
'gravatar_url': gravatar_url,
'is_admin': self.current_user in settings.ADMINS,
'name': self.current_user
}
self.render("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
class QueryFormatHandler(BaseAuthenticatedHandler):
def post(self):
arguments = json.loads(self.request.body)
query = arguments.get("query", "")
self.write(sqlparse.format(query, reindent=True, keyword_case='upper'))
class StatusHandler(BaseAuthenticatedHandler):
def get(self):
status = {}
info = self.redis_connection.info()
status['redis_used_memory'] = info['used_memory_human']
status['queries_count'] = data.models.Query.objects.count()
status['query_results_count'] = data.models.QueryResult.objects.count()
status['dashboards_count'] = data.models.Dashboard.objects.count()
status['widgets_count'] = data.models.Widget.objects.count()
status['workers'] = [self.redis_connection.hgetall(w)
for w in self.redis_connection.smembers('workers')]
manager_status = self.redis_connection.hgetall('manager:status')
status['manager'] = manager_status
status['manager']['queue_size'] = self.redis_connection.zcard('jobs')
self.write_json(status)
class WidgetsHandler(BaseAuthenticatedHandler):
def post(self, widget_id=None):
widget_properties = json.loads(self.request.body)
widget_properties['options'] = json.dumps(widget_properties['options'])
widget = data.models.Widget(**widget_properties)
widget.save()
layout = json.loads(widget.dashboard.layout)
new_row = True
if len(layout) == 0 or widget.width == 2:
layout.append([widget.id])
elif len(layout[-1]) == 1:
neighbour_widget = data.models.Widget.objects.get(pk=layout[-1][0])
if neighbour_widget.width == 1:
layout[-1].append(widget.id)
new_row = False
else:
layout.append([widget.id])
else:
layout.append([widget.id])
widget.dashboard.layout = json.dumps(layout)
widget.dashboard.save()
self.write_json({'widget': widget.to_dict(), 'layout': layout, 'new_row': new_row})
def delete(self, widget_id):
widget_id = int(widget_id)
widget = data.models.Widget.objects.get(pk=widget_id)
# TODO: reposition existing ones
layout = json.loads(widget.dashboard.layout)
layout = map(lambda row: filter(lambda w: w != widget_id, row), layout)
layout = filter(lambda row: len(row) > 0, layout)
widget.dashboard.layout = json.dumps(layout)
widget.dashboard.save()
widget.delete()
class DashboardHandler(BaseAuthenticatedHandler):
def get(self, dashboard_slug=None):
if dashboard_slug:
dashboard = data.models.Dashboard.objects.prefetch_related('widgets__visualization__query__latest_query_data').get(slug=dashboard_slug)
self.write_json(dashboard.to_dict(with_widgets=True))
else:
dashboards = [d.to_dict() for d in
data.models.Dashboard.objects.filter(is_archived=False)]
self.write_json(dashboards)
def post(self, dashboard_id):
if dashboard_id:
dashboard_properties = json.loads(self.request.body)
dashboard = data.models.Dashboard.objects.get(pk=dashboard_id)
dashboard.layout = dashboard_properties['layout']
dashboard.name = dashboard_properties['name']
dashboard.save()
self.write_json(dashboard.to_dict(with_widgets=True))
else:
dashboard_properties = json.loads(self.request.body)
dashboard = data.models.Dashboard(name=dashboard_properties['name'],
user=self.current_user,
layout='[]')
dashboard.save()
self.write_json(dashboard.to_dict())
def delete(self, dashboard_slug):
dashboard = data.models.Dashboard.objects.get(slug=dashboard_slug)
dashboard.is_archived = True
dashboard.save()
class QueriesHandler(BaseAuthenticatedHandler):
def post(self, id=None):
query_def = json.loads(self.request.body)
if 'created_at' in query_def:
query_def['created_at'] = dateutil.parser.parse(query_def['created_at'])
query_def.pop('latest_query_data', None)
query_def.pop('visualizations', None)
if id:
query = data.models.Query(**query_def)
fields = query_def.keys()
fields.remove('id')
query.save(update_fields=fields)
else:
query_def['user'] = self.current_user
query = data.models.Query(**query_def)
query.save()
query.create_default_visualizations()
self.write_json(query.to_dict(with_result=False))
def get(self, id=None):
if id:
q = data.models.Query.objects.get(pk=id)
if q:
self.write_json(q.to_dict(with_visualizations=True))
else:
self.send_error(404)
else:
self.write_json([q.to_dict(with_result=False, with_stats=True) for q in data.models.Query.all_queries()])
class QueryResultsHandler(BaseAuthenticatedHandler):
def get(self, query_result_id):
query_result = self.data_manager.get_query_result_by_id(query_result_id)
if query_result:
self.write_json({'query_result': query_result.to_dict(parse_data=True)})
else:
self.send_error(404)
def post(self, _):
params = json.loads(self.request.body)
if params['ttl'] == 0:
query_result = None
else:
query_result = self.data_manager.get_query_result(params['query'], int(params['ttl']))
if query_result:
self.write_json({'query_result': query_result.to_dict(parse_data=True)})
else:
job = self.data_manager.add_job(params['query'], data.Job.HIGH_PRIORITY)
self.write({'job': job.to_dict()})
class VisualizationHandler(BaseAuthenticatedHandler):
def get(self, id):
pass
def post(self, id=None):
kwargs = json.loads(self.request.body)
kwargs['options'] = json.dumps(kwargs['options'])
if id:
vis = data.models.Visualization(**kwargs)
fields = kwargs.keys()
fields.remove('id')
vis.save(update_fields=fields)
else:
vis = data.models.Visualization(**kwargs)
vis.save()
self.write_json(vis.to_dict(with_query=False))
def delete(self, id):
vis = data.models.Visualization.objects.get(pk=id)
vis.delete()
class CsvQueryResultsHandler(BaseAuthenticatedHandler):
def get_current_user(self):
user = super(CsvQueryResultsHandler, self).get_current_user()
if not user:
api_key = self.get_argument("api_key", None)
query = data.models.Query.objects.get(pk=self.path_args[0])
if query.api_key and query.api_key == api_key:
user = "API-Key=%s" % api_key
return user
def get(self, query_id, result_id=None):
if not result_id:
query = data.models.Query.objects.get(pk=query_id)
if query:
result_id = query.latest_query_data_id
query_result = result_id and self.data_manager.get_query_result_by_id(result_id)
if query_result:
self.set_header("Content-Type", "text/csv; charset=UTF-8")
s = cStringIO.StringIO()
query_data = json.loads(query_result.data)
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
writer.writer = utils.UnicodeWriter(s)
writer.writeheader()
for row in query_data['rows']:
for k, v in row.iteritems():
if isinstance(v, numbers.Number) and (v > 1000 * 1000 * 1000 * 100):
row[k] = datetime.datetime.fromtimestamp(v/1000.0)
writer.writerow(row)
self.write(s.getvalue())
else:
self.send_error(404)
class JobsHandler(BaseAuthenticatedHandler):
def get(self, job_id=None):
if job_id:
# TODO: if finished, include the query result
job = data.Job.load(self.data_manager.redis_connection, job_id)
self.write({'job': job.to_dict()})
else:
raise NotImplemented
def delete(self, job_id):
job = data.Job.load(self.data_manager.redis_connection, job_id)
job.cancel()
def get_application(static_path, is_debug, redis_connection, data_manager):
return tornado.web.Application([(r"/", MainHandler),
(r"/ping", PingHandler),
(r"/api/queries/([0-9]*)/results(?:/([0-9]*))?.csv", CsvQueryResultsHandler),
(r"/api/queries/format", QueryFormatHandler),
(r"/api/queries(?:/([0-9]*))?", QueriesHandler),
(r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler),
(r"/api/jobs/(.*)", JobsHandler),
(r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler),
(r"/api/widgets(?:/([0-9]*))?", WidgetsHandler),
(r"/api/dashboards(?:/(.*))?", DashboardHandler),
(r"/admin/(.*)", MainHandler),
(r"/dashboard/(.*)", MainHandler),
(r"/queries(.*)", MainHandler),
(r"/login", GoogleLoginHandler),
(r"/status.json", StatusHandler),
(r"/(.*)", tornado.web.StaticFileHandler,
{"path": static_path})],
template_path=static_path,
static_path=static_path,
debug=is_debug,
login_url="/login",
cookie_secret=settings.COOKIE_SECRET,
redis_connection=redis_connection,
data_manager=data_manager)
if __name__ == '__main__':
tornado.options.define("port", default=8888, type=int)
tornado.options.define("debug", default=False, type=bool)
tornado.options.define("static", default=settings.STATIC_ASSETS_PATH, type=str)
tornado.options.parse_command_line()
root_path = os.path.dirname(__file__)
static_path = os.path.abspath(os.path.join(root_path, tornado.options.options.static))
url = urlparse.urlparse(settings.REDIS_URL)
redis_connection = redis.StrictRedis(host=url.hostname, port=url.port, db=0, password=url.password)
data_manager = data.Manager(redis_connection, settings.INTERNAL_DB_CONNECTION_STRING,
settings.MAX_CONNECTIONS)
logging.info("re:dash web server stating on port: %d...", tornado.options.options.port)
logging.info("UI assets path: %s...", static_path)
application = get_application(static_path, tornado.options.options.debug,
redis_connection, data_manager)
application.listen(tornado.options.options.port)
tornado.ioloop.IOLoop.instance().start()

View File

@@ -31,6 +31,7 @@
</button>
<a class="navbar-brand" href="/"><strong>re:dash</strong></a>
</div>
{% raw %}
<div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav">
<li class="active" ng-show="pageTitle"><a class="page-title" ng-bind="pageTitle"></a></li>
@@ -42,13 +43,13 @@
<a href="#" ng-bind="name"></a>
<ul class="dropdown-menu">
<li ng-repeat="dashboard in group" role="presentation">
<a role="menu-item" ng-href="/dashboard/{{!dashboard.slug}}" ng-bind="dashboard.name"></a>
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
</ul>
</li>
</span>
<li ng-repeat="dashboard in otherDashboards">
<a role="menu-item" ng-href="/dashboard/{{!dashboard.slug}}" ng-bind="dashboard.name"></a>
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
</li>
<li class="divider"></li>
<li><a data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</a></li>
@@ -64,10 +65,11 @@
</ul>
<ul class="nav navbar-nav navbar-right">
<p class="navbar-text avatar">
<img ng-src="{{!currentUser.gravatar_url}}" class="img-circle" alt="{{!currentUser.name}}" width="40" height="40"/>
<img ng-src="{{currentUser.gravatar_url}}" class="img-circle" alt="{{currentUser.name}}" width="40" height="40"/>
</p>
</ul>
</div>
{% endraw %}
</div>
</nav>
@@ -119,13 +121,13 @@
<!-- endbuild -->
<script>
var currentUser = {% raw user %};
var currentUser = {{ user|safe }};
currentUser.canEdit = function(object) {
return object.user && (object.user.indexOf(currentUser.name) != -1);
};
{% raw analytics %}
{{ analytics|safe }}
</script>
</body>

View File

@@ -177,7 +177,7 @@
row: rowIndex+1,
ySize: 1,
xSize: widget.width,
name: widget.visualization.name
name: widget.visualization.query.name
});
});
});

View File

@@ -8,6 +8,7 @@ renderers.directive('visualizationRenderer', function() {
queryResult: '='
},
template: '<div ng-switch on="visualization.type">' +
'<grid-renderer ng-switch-when="TABLE" options="visualization.options" query-result="queryResult"></grid-renderer>' +
'<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
'<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
'</div>',

41
redash/__init__.py Normal file
View File

@@ -0,0 +1,41 @@
import json
import urlparse
from flask import Flask, make_response
from flask.ext.restful import Api
from flask_peewee.db import Database
import redis
from redash import settings, utils
__version__ = '0.3.1'
app = Flask(__name__,
template_folder=settings.STATIC_ASSETS_PATH,
static_folder=settings.STATIC_ASSETS_PATH,
static_path='/static')
api = Api(app)
# configure our database
settings.DATABASE_CONFIG.update({'threadlocals': True})
app.config['DATABASE'] = settings.DATABASE_CONFIG
db = Database(app)
from redash.authentication import setup_authentication
auth = setup_authentication(app)
@api.representation('application/json')
def json_representation(data, code, headers=None):
resp = make_response(json.dumps(data, cls=utils.JSONEncoder), code)
resp.headers.extend(headers or {})
return resp
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)
from redash import data
data_manager = data.Manager(redis_connection, db)
from redash import controllers

59
redash/authentication.py Normal file
View File

@@ -0,0 +1,59 @@
import functools
import hashlib
import hmac
from flask import request, make_response
from flask.ext.googleauth import GoogleFederated
import time
from werkzeug.contrib.fixers import ProxyFix
import werkzeug.wrappers
from redash import models, settings
def sign(key, path, expires):
if not key:
return None
h = hmac.new(str(key), msg=path, digestmod=hashlib.sha1)
h.update(str(expires))
return h.hexdigest()
class HMACAuthentication(object):
def __init__(self, auth):
self.auth = auth
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)
# 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 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 decorated
def setup_authentication(app):
openid_auth = GoogleFederated(settings.GOOGLE_APPS_DOMAIN, app)
app.wsgi_app = ProxyFix(app.wsgi_app)
app.secret_key = settings.COOKIE_SECRET
return HMACAuthentication(openid_auth)

339
redash/controllers.py Normal file
View File

@@ -0,0 +1,339 @@
"""
Flask-restful based API implementation for re:dash.
Currently the Flask server is used to serve the static assets (and the Angular.js app),
but this is only due to configuration issues and temporary.
"""
import csv
import hashlib
import json
import numbers
import cStringIO
import datetime
from flask import g, render_template, send_from_directory, make_response, request, jsonify
from flask.ext.restful import Resource, abort
import sqlparse
from redash import settings, utils
from redash import data
from redash import app, auth, api, redis_connection, data_manager
from redash import models
@app.route('/ping', methods=['GET'])
def ping():
return 'PONG.'
@app.route('/admin/<anything>')
@app.route('/dashboard/<anything>')
@app.route('/queries')
@app.route('/queries/<anything>')
@app.route('/')
@auth.required
def index(anything=None):
email_md5 = hashlib.md5(g.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']
}
return render_template("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
@app.route('/status.json')
@auth.required
def status_api():
status = {}
info = redis_connection.info()
status['redis_used_memory'] = info['used_memory_human']
status['queries_count'] = models.Query.select().count()
status['query_results_count'] = models.QueryResult.select().count()
status['dashboards_count'] = models.Dashboard.select().count()
status['widgets_count'] = models.Widget.select().count()
status['workers'] = [redis_connection.hgetall(w)
for w in redis_connection.smembers('workers')]
manager_status = redis_connection.hgetall('manager:status')
status['manager'] = manager_status
status['manager']['queue_size'] = redis_connection.zcard('jobs')
return jsonify(status)
@app.route('/api/queries/format', methods=['POST'])
@auth.required
def format_sql_query():
arguments = request.get_json(force=True)
query = arguments.get("query", "")
return sqlparse.format(query, reindent=True, keyword_case='upper')
class BaseResource(Resource):
decorators = [auth.required]
@property
def current_user(self):
return g.user['email']
class DashboardListAPI(BaseResource):
def get(self):
dashboards = [d.to_dict() for d in
models.Dashboard.select().where(models.Dashboard.is_archived==False)]
return dashboards
def post(self):
dashboard_properties = request.get_json(force=True)
dashboard = models.Dashboard(name=dashboard_properties['name'],
user=self.current_user,
layout='[]')
dashboard.save()
return dashboard.to_dict()
class DashboardAPI(BaseResource):
def get(self, dashboard_slug=None):
try:
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
except models.Dashboard.DoesNotExist:
abort(404)
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)
dashboard.layout = dashboard_properties['layout']
dashboard.name = dashboard_properties['name']
dashboard.save()
return dashboard.to_dict(with_widgets=True)
def delete(self, dashboard_slug):
dashboard = models.Dashboard.get_by_slug(dashboard_slug)
dashboard.is_archived = True
dashboard.save()
api.add_resource(DashboardListAPI, '/api/dashboards', endpoint='dashboards')
api.add_resource(DashboardAPI, '/api/dashboards/<dashboard_slug>', endpoint='dashboard')
class WidgetListAPI(BaseResource):
def post(self):
widget_properties = request.get_json(force=True)
widget_properties['options'] = json.dumps(widget_properties['options'])
widget_properties.pop('id', None)
widget_properties['dashboard'] = widget_properties.pop('dashboard_id')
widget_properties['visualization'] = widget_properties.pop('visualization_id')
widget = models.Widget(**widget_properties)
widget.save()
layout = json.loads(widget.dashboard.layout)
new_row = True
if len(layout) == 0 or widget.width == 2:
layout.append([widget.id])
elif len(layout[-1]) == 1:
neighbour_widget = models.Widget.get(models.Widget.id == layout[-1][0])
if neighbour_widget.width == 1:
layout[-1].append(widget.id)
new_row = False
else:
layout.append([widget.id])
else:
layout.append([widget.id])
widget.dashboard.layout = json.dumps(layout)
widget.dashboard.save()
return {'widget': widget.to_dict(), 'layout': layout, 'new_row': new_row}
class WidgetAPI(BaseResource):
def delete(self, widget_id):
widget = models.Widget.get(models.Widget.id == widget_id)
# TODO: reposition existing ones
layout = json.loads(widget.dashboard.layout)
layout = map(lambda row: filter(lambda w: w != widget_id, row), layout)
layout = filter(lambda row: len(row) > 0, layout)
widget.dashboard.layout = json.dumps(layout)
widget.dashboard.save()
widget.delete_instance()
api.add_resource(WidgetListAPI, '/api/widgets', endpoint='widgets')
api.add_resource(WidgetAPI, '/api/widgets/<int:widget_id>', endpoint='widget')
class QueryListAPI(BaseResource):
def post(self):
query_def = request.get_json(force=True)
# id, created_at, api_key
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data']:
query_def.pop(field, None)
query_def['user'] = self.current_user
query = models.Query(**query_def)
query.save()
query.create_default_visualizations()
return query.to_dict(with_result=False)
def get(self):
return [q.to_dict(with_result=False, with_stats=True) for q in models.Query.all_queries()]
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']:
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()
query = models.Query.get_by_id(query_id)
return query.to_dict(with_result=False)
def get(self, query_id):
q = models.Query.get(models.Query.id == query_id)
if q:
return q.to_dict(with_visualizations=True)
else:
abort(404, message="Query not found.")
api.add_resource(QueryListAPI, '/api/queries', endpoint='queries')
api.add_resource(QueryAPI, '/api/queries/<query_id>', endpoint='query')
class VisualizationListAPI(BaseResource):
def post(self):
kwargs = request.get_json(force=True)
kwargs['options'] = json.dumps(kwargs['options'])
kwargs['query'] = kwargs.pop('query_id')
vis = models.Visualization(**kwargs)
vis.save()
return vis.to_dict(with_query=False)
class VisualizationAPI(BaseResource):
def post(self, visualization_id):
kwargs = request.get_json(force=True)
if 'options' in kwargs:
kwargs['options'] = json.dumps(kwargs['options'])
kwargs.pop('id', None)
update = models.Visualization.update(**kwargs).where(models.Visualization.id == visualization_id)
update.execute()
vis = models.Visualization.get_by_id(visualization_id)
return vis.to_dict(with_query=False)
def delete(self, visualization_id):
vis = models.Visualization.get(models.Visualization.id == visualization_id)
vis.delete_instance()
api.add_resource(VisualizationListAPI, '/api/visualizations', endpoint='visualizations')
api.add_resource(VisualizationAPI, '/api/visualizations/<visualization_id>', endpoint='visualization')
class QueryResultListAPI(BaseResource):
def post(self):
params = request.json
if params['ttl'] == 0:
query_result = None
else:
query_result = data_manager.get_query_result(params['query'], int(params['ttl']))
if query_result:
return {'query_result': query_result.to_dict(parse_data=True)}
else:
job = data_manager.add_job(params['query'], data.Job.HIGH_PRIORITY)
return {'job': job.to_dict()}
class QueryResultAPI(BaseResource):
def get(self, query_result_id):
query_result = data_manager.get_query_result_by_id(query_result_id)
if query_result:
return {'query_result': query_result.to_dict(parse_data=True)}
else:
abort(404)
class CsvQueryResultsAPI(BaseResource):
def get(self, query_id, query_result_id=None):
if not query_result_id:
query = models.Query.get(models.Query.id == query_id)
if query:
query_result_id = query._data['latest_query_data']
query_result = query_result_id and data_manager.get_query_result_by_id(query_result_id)
if query_result:
s = cStringIO.StringIO()
query_data = json.loads(query_result.data)
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
writer.writer = utils.UnicodeWriter(s)
writer.writeheader()
for row in query_data['rows']:
for k, v in row.iteritems():
if isinstance(v, numbers.Number) and (v > 1000 * 1000 * 1000 * 100):
row[k] = datetime.datetime.fromtimestamp(v/1000.0)
writer.writerow(row)
return make_response(s.getvalue(), 200, {'Content-Type': "text/csv; charset=UTF-8"})
else:
abort(404)
api.add_resource(CsvQueryResultsAPI, '/api/queries/<query_id>/results/<query_result_id>.csv',
'/api/queries/<query_id>/results.csv',
endpoint='csv_query_results')
api.add_resource(QueryResultListAPI, '/api/query_results', endpoint='query_results')
api.add_resource(QueryResultAPI, '/api/query_results/<query_result_id>', endpoint='query_result')
class JobAPI(BaseResource):
def get(self, job_id):
# TODO: if finished, include the query result
job = data.Job.load(data_manager.redis_connection, job_id)
return {'job': job.to_dict()}
def delete(self, job_id):
job = data.Job.load(data_manager.redis_connection, job_id)
job.cancel()
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)
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -1,4 +1,2 @@
from manager import Manager
from worker import Job
import models
import utils

View File

@@ -1,18 +1,17 @@
"""
Data manager. Used to manage and coordinate execution of queries.
"""
import collections
from contextlib import contextmanager
import collections
import json
import time
import logging
import psycopg2
import qr
import redis
import time
import worker
import settings
from utils import gen_query_hash
from redash import settings
from redash.data import worker
from redash.utils import gen_query_hash
class QueryResult(collections.namedtuple('QueryData', 'id query data runtime retrieved_at query_hash')):
def to_dict(self, parse_data=False):
@@ -25,10 +24,10 @@ class QueryResult(collections.namedtuple('QueryData', 'id query data runtime ret
class Manager(object):
def __init__(self, redis_connection, db_connection_string, db_max_connections):
def __init__(self, redis_connection, db):
self.redis_connection = redis_connection
self.db = db
self.workers = []
self.db_connection_string = db_connection_string
self.queue = qr.PriorityQueue("jobs", **self.redis_connection.connection_pool.connection_kwargs)
self.max_retries = 5
self.status = {
@@ -150,15 +149,15 @@ class Manager(object):
return data
def start_workers(self, workers_count, connection_string):
def start_workers(self, workers_count, connection_type, connection_string):
if self.workers:
return self.workers
if getattr(settings, 'CONNECTION_ADAPTER', None) == "mysql":
import query_runner_mysql
from redash.data import query_runner_mysql
runner = query_runner_mysql.mysql(connection_string)
else:
import query_runner
from redash.data import query_runner
runner = query_runner.redshift(connection_string)
redis_connection_params = self.redis_connection.connection_pool.connection_kwargs
@@ -176,17 +175,18 @@ class Manager(object):
@contextmanager
def db_transaction(self):
connection = psycopg2.connect(self.db_connection_string)
cursor = connection.cursor()
self.db.connect_db()
cursor = self.db.database.get_cursor()
try:
yield cursor
except:
connection.rollback()
self.db.database.rollback()
raise
else:
connection.commit()
self.db.database.commit()
finally:
connection.close()
self.db.close_db(None)
def _save_status(self):
self.redis_connection.hmset('manager:status', self.status)

View File

@@ -8,10 +8,12 @@ query language (for example: HiveQL).
"""
import logging
import json
import psycopg2
import sys
import select
from .utils import JSONEncoder
import psycopg2
from redash.utils import JSONEncoder
def redshift(connection_string):
def column_friendly_name(column_name):
@@ -64,4 +66,4 @@ def redshift(connection_string):
return json_data, error
return query_runner
return query_runner

View File

@@ -10,8 +10,7 @@ import logging
import json
import MySQLdb
import sys
import select
from .utils import JSONEncoder
from redash.utils import JSONEncoder
def mysql(connection_string):
if connection_string.endswith(';'):

View File

@@ -11,7 +11,7 @@ import time
import signal
import setproctitle
import redis
from utils import gen_query_hash
from redash.utils import gen_query_hash
class Job(object):

View File

@@ -1,24 +1,36 @@
"""
Django ORM based models to describe the data model of re:dash.
"""
import hashlib
import json
import hashlib
import time
from django.db import models
from django.template.defaultfilters import slugify
import utils
import datetime
from flask.ext.peewee.utils import slugify
import peewee
from redash import db, utils
class QueryResult(models.Model):
id = models.AutoField(primary_key=True)
query_hash = models.CharField(max_length=32)
query = models.TextField()
data = models.TextField()
runtime = models.FloatField()
retrieved_at = models.DateTimeField()
#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 QueryResult(db.Model):
id = peewee.PrimaryKeyField()
query_hash = peewee.CharField(max_length=32, index=True)
query = peewee.TextField()
data = peewee.TextField()
runtime = peewee.FloatField()
retrieved_at = peewee.DateTimeField()
class Meta:
app_label = 'redash'
db_table = 'query_results'
def to_dict(self):
@@ -35,20 +47,19 @@ class QueryResult(models.Model):
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at)
class Query(models.Model):
id = models.AutoField(primary_key=True)
latest_query_data = models.ForeignKey(QueryResult)
name = models.CharField(max_length=255)
description = models.CharField(max_length=4096)
query = models.TextField()
query_hash = models.CharField(max_length=32)
api_key = models.CharField(max_length=40)
ttl = models.IntegerField()
user = models.CharField(max_length=360)
created_at = models.DateTimeField(auto_now_add=True)
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)
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)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
class Meta:
app_label = 'redash'
db_table = 'queries'
def create_default_visualizations(self):
@@ -57,11 +68,10 @@ class Query(models.Model):
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):
d = {
'id': self.id,
'latest_query_data_id': self.latest_query_data_id,
'latest_query_data_id': self._data.get('latest_query_data', None),
'name': self.name,
'description': self.description,
'query': self.query,
@@ -79,12 +89,12 @@ class Query(models.Model):
d['last_retrieved_at'] = self.last_retrieved_at
d['times_retrieved'] = self.times_retrieved
if with_result and self.latest_query_data_id:
d['latest_query_data'] = self.latest_query_data.to_dict()
if with_visualizations:
d['visualizations'] = [vis.to_dict(with_query=False)
for vis in self.visualizations.all()]
for vis in self.visualizations]
if with_result and self.latest_query_data:
d['latest_query_data'] = self.latest_query_data.to_dict()
return d
@@ -103,7 +113,7 @@ LEFT OUTER JOIN
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.objects.raw(query)
return cls.raw(query)
def save(self, *args, **kwargs):
self.query_hash = utils.gen_query_hash(self.query)
@@ -119,23 +129,25 @@ LEFT OUTER JOIN
return unicode(self.id)
class Dashboard(models.Model):
id = models.AutoField(primary_key=True)
slug = models.CharField(max_length=140)
name = models.CharField(max_length=100)
user = models.CharField(max_length=360)
layout = models.TextField()
is_archived = models.BooleanField(default=False)
class Dashboard(db.Model):
id = peewee.PrimaryKeyField()
slug = peewee.CharField(max_length=140, index=True)
name = peewee.CharField(max_length=100)
user = peewee.CharField(max_length=360)
layout = peewee.TextField()
is_archived = peewee.BooleanField(default=False, index=True)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
class Meta:
app_label = 'redash'
db_table = 'dashboards'
def to_dict(self, with_widgets=False):
layout = json.loads(self.layout)
if with_widgets:
widgets = {w.id: w.to_dict() for w in self.widgets.all()}
widgets = Widget.select(Widget, Visualization, Query, QueryResult).\
where(Widget.dashboard == self.id).join(Visualization).join(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:
widgets_layout = None
@@ -149,26 +161,34 @@ class Dashboard(models.Model):
'widgets': widgets_layout
}
@classmethod
def get_by_slug(cls, slug):
return cls.get(cls.slug==slug)
def save(self, *args, **kwargs):
# TODO: make sure slug is unique
if not self.slug:
self.slug = slugify(self.name)
tries = 1
while self.select().where(Dashboard.slug == self.slug).first() is not None:
self.slug = slugify(self.name) + "_{0}".format(tries)
tries += 1
super(Dashboard, self).save(*args, **kwargs)
def __unicode__(self):
return u"%s=%s" % (self.id, self.name)
class Visualization(models.Model):
id = models.AutoField(primary_key=True)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='visualizations')
name = models.CharField(max_length=255)
description = models.CharField(max_length=4096)
options = models.TextField()
class Visualization(BaseModel):
id = peewee.PrimaryKeyField()
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)
options = peewee.TextField()
class Meta:
app_label = 'redash'
db_table = 'visualizations'
def to_dict(self, with_query=True):
@@ -186,31 +206,50 @@ class Visualization(models.Model):
return d
def __unicode__(self):
return u"%s=>%s" % (self.id, self.query_id)
return u"%s %s" % (self.id, self.type)
class Widget(models.Model):
id = models.AutoField(primary_key=True)
type = models.CharField(max_length=100)
query = models.ForeignKey(Query, related_name='widgets')
visualization = models.ForeignKey(Visualization, related_name='widgets')
width = models.IntegerField()
options = models.TextField()
dashboard = models.ForeignKey(Dashboard, related_name='widgets')
class Widget(db.Model):
id = peewee.PrimaryKeyField()
visualization = peewee.ForeignKeyField(Visualization, related_name='widgets')
width = peewee.IntegerField()
options = peewee.TextField()
dashboard = peewee.ForeignKeyField(Dashboard, related_name='widgets', index=True)
created_at = peewee.DateTimeField(default=datetime.datetime.now)
# unused; kept for backward compatability:
type = peewee.CharField(max_length=100, null=True)
query_id = peewee.IntegerField(null=True)
class Meta:
app_label = 'redash'
db_table = 'widgets'
def to_dict(self):
return {
'id': self.id,
'type': self.type,
'width': self.width,
'options': json.loads(self.options),
'visualization': self.visualization.to_dict(),
'dashboard_id': self.dashboard_id
'dashboard_id': self._data['dashboard']
}
def __unicode__(self):
return u"%s=>%s" % (self.id, self.dashboard_id)
return u"%s" % self.id
all_models = (QueryResult, Query, Dashboard, Visualization, Widget)
def create_db(create_tables, drop_tables):
db.connect_db()
for model in all_models:
if drop_tables and model.table_exists():
# TODO: submit PR to peewee to allow passing cascade option to drop_table.
db.database.execute_sql('DROP TABLE %s CASCADE' % model._meta.db_table)
#model.drop_table()
if create_tables:
model.create_table()
db.close_db(None)

View File

@@ -1,19 +1,19 @@
"""
Example settings module. You should make your own copy as settings.py and enter the real settings.
"""
import django.conf
REDIS_URL = "redis://localhost:6379"
# Either "pg" or "mysql"
CONNECTION_ADAPTER = "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 string for the operational databases (where we store the queries, results, etc)
INTERNAL_DB_CONNECTION_STRING = "dbname=postgres"
# 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 = ""
@@ -24,18 +24,6 @@ ALLOWED_USERS = []
ADMINS = []
STATIC_ASSETS_PATH = "../rd_ui/dist/"
WORKERS_COUNT = 2
MAX_CONNECTIONS = 3
COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f"
LOG_LEVEL = "INFO"
ANALYTICS = ""
# Configuration of the operational database for the Django models
django.conf.settings.configure(DATABASES = { 'default': {
'ENGINE': 'dbpool.db.backends.postgresql_psycopg2',
'OPTIONS': {'MAX_CONNS': 10, 'MIN_CONNS': 1},
'NAME': 'postgres',
'USER': '',
'PASSWORD': '',
'HOST': '',
'PORT': '',
},}, TIME_ZONE = 'UTC')
ANALYTICS = ""

View File

@@ -36,6 +36,10 @@ class JSONEncoder(json.JSONEncoder):
super(JSONEncoder, self).default(o)
def json_dumps(data):
return json.dumps(data, cls=JSONEncoder)
class UnicodeWriter:
"""
A CSV writer which will write rows to CSV file "f",

25
requirements.txt Normal file
View File

@@ -0,0 +1,25 @@
Flask==0.10.1
Flask-GoogleAuth==0.4
Flask-RESTful==0.2.10
Jinja2==2.7.2
MarkupSafe==0.18
WTForms==1.0.5
Werkzeug==0.9.4
aniso8601==0.82
atfork==0.1.2
blinker==1.3
flask-peewee==0.6.5
itsdangerous==0.23
peewee==2.2.0
psycopg2==2.5.1
python-dateutil==2.1
pytz==2013.9
qr==0.6.0
redis==2.7.5
requests==2.2.0
setproctitle==1.1.8
six==1.5.2
sqlparse==0.1.8
wsgiref==0.1.2
wtf-peewee==0.2.2
Flask-Script==0.6.6

24
tests/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
from unittest import TestCase
from redash import settings, db, app
import redash.models
# TODO: this isn't pretty... :-)
settings.DATABASE_CONFIG = {
'name': 'circle_test',
'engine': 'peewee.PostgresqlDatabase',
'threadlocals': True
}
app.config['DATABASE'] = settings.DATABASE_CONFIG
db.load_database()
for model in redash.models.all_models:
model._meta.database = db.database
class BaseTestCase(TestCase):
def setUp(self):
redash.models.create_db(True, True)
def tearDown(self):
db.close_db(None)
redash.models.create_db(False, True)

323
tests/controllers.py Normal file
View File

@@ -0,0 +1,323 @@
from contextlib import contextmanager
import json
import time
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.utils import json_dumps
from redash.authentication import sign
@contextmanager
def authenticated_user(c, user='test@example.com', name='John Test'):
with c.session_transaction() as sess:
sess['openid'] = {'email': user, 'name': name}
yield
def json_request(method, path, data=None):
if data:
response = method(path, data=json_dumps(data))
else:
response = method(path)
if response.data:
response.json = json.loads(response.data)
else:
response.json = None
return response
class AuthenticationTestMixin():
def test_redirects_when_not_authenticated(self):
with app.test_client() as c:
for path in self.paths:
rv = c.get(path)
self.assertEquals(302, rv.status_code)
def test_returns_content_when_authenticated(self):
with app.test_client() as c, authenticated_user(c):
for path in self.paths:
rv = c.get(path)
self.assertEquals(200, rv.status_code)
class PingTest(BaseTestCase):
def test_ping(self):
with app.test_client() as c:
rv = c.get('/ping')
self.assertEquals(200, rv.status_code)
self.assertEquals('PONG.', rv.data)
class IndexTest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/', '/dashboard/example', '/queries/1', '/admin/status']
super(IndexTest, self).setUp()
class StatusTest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/status.json']
super(StatusTest, self).setUp()
class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/api/dashboards']
super(DashboardAPITest, self).setUp()
def test_get_dashboard(self):
d1 = dashboard_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = c.get('/api/dashboards/{0}'.format(d1.slug))
self.assertEquals(rv.status_code, 200)
self.assertDictEqual(json.loads(rv.data), d1.to_dict(with_widgets=True))
def test_get_non_existint_dashbaord(self):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/api/dashboards/not_existing')
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):
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['layout'], [])
def test_update_dashboard(self):
d = dashboard_factory.create()
new_name = 'New Name'
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/dashboards/{0}'.format(d.id),
data={'name': new_name, 'layout': '[]'})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], new_name)
def test_delete_dashboard(self):
d = dashboard_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/dashboards/{0}'.format(d.slug))
self.assertEquals(rv.status_code, 200)
d = models.Dashboard.get_by_slug(d.slug)
self.assertTrue(d.is_archived)
class WidgetAPITest(BaseTestCase):
def create_widget(self, dashboard, visualization, width=1):
data = {
'visualization_id': visualization.id,
'dashboard_id': dashboard.id,
'options': {},
'width': width
}
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/widgets', data=data)
return rv
def test_create_widget(self):
dashboard = dashboard_factory.create()
vis = visualization_factory.create()
rv = self.create_widget(dashboard, vis)
self.assertEquals(rv.status_code, 200)
dashboard = models.Dashboard.get(models.Dashboard.id == dashboard.id)
self.assertEquals(unicode(rv.json['layout']), dashboard.layout)
self.assertEquals(dashboard.widgets, 1)
self.assertEquals(rv.json['layout'], [[rv.json['widget']['id']]])
self.assertEquals(rv.json['new_row'], True)
rv2 = self.create_widget(dashboard, vis)
self.assertEquals(dashboard.widgets, 2)
self.assertEquals(rv2.json['layout'],
[[rv.json['widget']['id'], rv2.json['widget']['id']]])
self.assertEquals(rv2.json['new_row'], False)
rv3 = self.create_widget(dashboard, vis)
self.assertEquals(rv3.json['new_row'], True)
rv4 = self.create_widget(dashboard, vis, width=2)
self.assertEquals(rv4.json['layout'],
[[rv.json['widget']['id'], rv2.json['widget']['id']],
[rv3.json['widget']['id']],
[rv4.json['widget']['id']]])
self.assertEquals(rv4.json['new_row'], True)
def test_delete_widget(self):
widget = widget_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/widgets/{0}'.format(widget.id))
self.assertEquals(rv.status_code, 200)
dashboard = models.Dashboard.get_by_slug(widget.dashboard.slug)
self.assertEquals(dashboard.widgets.count(), 0)
self.assertEquals(dashboard.layout, '[]')
# TODO: test how it updates the layout
class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = ['/api/queries']
super(QueryAPITest, self).setUp()
def test_update_query(self):
query = query_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/queries/{0}'.format(query.id), data={'name': 'Testing'})
self.assertEqual(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'Testing')
def test_create_query(self):
user = 'test@everything.me'
query_data = {
'name': 'Testing',
'description': 'Description',
'query': 'SELECT 1',
'ttl': 3600
}
with app.test_client() as c, authenticated_user(c, user=user):
rv = json_request(c.post, '/api/queries', data=query_data)
self.assertEquals(rv.status_code, 200)
self.assertDictContainsSubset(query_data, rv.json)
self.assertEquals(rv.json['user'], user)
self.assertIsNotNone(rv.json['api_key'])
self.assertIsNotNone(rv.json['query_hash'])
query = models.Query.get_by_id(rv.json['id'])
self.assertEquals(len(list(query.visualizations)), 1)
def test_get_query(self):
query = query_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.get, '/api/queries/{0}'.format(query.id))
self.assertEquals(rv.status_code, 200)
d = query.to_dict(with_visualizations=True)
d.pop('created_at')
self.assertDictContainsSubset(d, rv.json)
def test_get_all_queries(self):
queries = [query_factory.create() for _ in range(10)]
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.get, '/api/queries')
self.assertEquals(rv.status_code, 200)
self.assertEquals(len(rv.json), 10)
class VisualizationAPITest(BaseTestCase):
def test_create_visualization(self):
query = query_factory.create()
data = {
'query_id': query.id,
'name': 'Chart',
'description':'',
'options': {},
'type': 'CHART'
}
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/visualizations', data=data)
self.assertEquals(rv.status_code, 200)
data.pop('query_id')
self.assertDictContainsSubset(data, rv.json)
def test_delete_visualization(self):
visualization = visualization_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.delete, '/api/visualizations/{0}'.format(visualization.id))
self.assertEquals(rv.status_code, 200)
self.assertEquals(models.Visualization.select().count(), 0)
def test_update_visualization(self):
visualization = visualization_factory.create()
with app.test_client() as c, authenticated_user(c):
rv = json_request(c.post, '/api/visualizations/{0}'.format(visualization.id),
data={'name': 'After Update'})
self.assertEquals(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'After Update')
class QueryResultAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = []
super(QueryResultAPITest, self).setUp()
class JobAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
self.paths = []
super(JobAPITest, self).setUp()
class CsvQueryResultAPITest(BaseTestCase, AuthenticationTestMixin):
def setUp(self):
super(CsvQueryResultAPITest, self).setUp()
self.paths = []
self.query_result = query_result_factory.create()
self.path = '/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id)
# TODO: factor out the HMAC authentication tests
def signature(self, expires):
return sign(self.query_result.query.api_key, self.path, expires)
def test_redirect_when_unauthenticated(self):
with app.test_client() as c:
rv = c.get(self.path)
self.assertEquals(rv.status_code, 302)
def test_redirect_for_wrong_signature(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id), query_string={'signature': 'whatever', 'expires': 0})
self.assertEquals(rv.status_code, 302)
def test_redirect_for_correct_signature_and_wrong_expires(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id), query_string={'signature': self.signature(0), 'expires': 0})
self.assertEquals(rv.status_code, 302)
def test_redirect_for_correct_signature_and_no_expires(self):
with app.test_client() as c:
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id), query_string={'signature': self.signature(time.time()+3600)})
self.assertEquals(rv.status_code, 302)
def test_redirect_for_correct_signature_and_expires_too_long(self):
with app.test_client() as c:
expires = time.time()+(10*3600)
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id), query_string={'signature': self.signature(expires), 'expires': expires})
self.assertEquals(rv.status_code, 302)
def test_returns_200_for_correct_signature(self):
with app.test_client() as c:
expires = time.time()+3600
rv = c.get('/api/queries/{0}/results/{1}.csv'.format(self.query_result.query.id, self.query_result.id), query_string={'signature': self.signature(expires), 'expires': expires})
self.assertEquals(rv.status_code, 200)
def test_returns_200_for_authenticated_user(self):
with app.test_client() as c, authenticated_user(c):
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)

58
tests/factories.py Normal file
View File

@@ -0,0 +1,58 @@
import datetime
import redash.models
class ModelFactory(object):
def __init__(self, model, **kwargs):
self.model = model
self.kwargs = kwargs
def _get_kwargs(self, override_kwargs):
kwargs = self.kwargs.copy()
kwargs.update(override_kwargs)
for key, arg in kwargs.items():
if callable(arg):
kwargs[key] = arg()
return kwargs
def instance(self, **override_kwargs):
kwargs = self._get_kwargs(override_kwargs)
return self.model(**kwargs)
def create(self, **override_kwargs):
kwargs = self._get_kwargs(override_kwargs)
return self.model.create(**kwargs)
dashboard_factory = ModelFactory(redash.models.Dashboard,
name='test', user='test@everything.me', layout='[]')
query_factory = ModelFactory(redash.models.Query,
name='New Query',
description='',
query='SELECT 1',
ttl=-1,
user='test@everything.me')
query_result_factory = ModelFactory(redash.models.QueryResult,
data='{"columns":{}, "rows":[]}',
runtime=1,
retrieved_at=datetime.datetime.now(),
query=query_factory.create,
query_hash='')
visualization_factory = ModelFactory(redash.models.Visualization,
type='CHART',
query=query_factory.create,
name='Chart',
description='',
options='{}')
widget_factory = ModelFactory(redash.models.Widget,
type='chart',
width=1,
options='{}',
dashboard=dashboard_factory.create,
visualization=visualization_factory.create)

15
tests/models.py Normal file
View File

@@ -0,0 +1,15 @@
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)