mirror of
https://github.com/valitydev/redash.git
synced 2024-11-07 09:28:51 +00:00
Merge branch 'master' of https://github.com/getredash/redash into design/new-data-source+query-editor
This commit is contained in:
commit
a26dbab28b
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
.cache
|
||||
.coverage.*
|
||||
.coveralls.yml
|
||||
.idea
|
||||
*.pyc
|
||||
|
21
Makefile
21
Makefile
@ -1,21 +0,0 @@
|
||||
NAME=redash
|
||||
VERSION=`python ./manage.py version`
|
||||
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
# VERSION gets evaluated every time it's referenced, therefore we need to use VERSION here instead of FULL_VERSION.
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
TEST_ARGS?=tests/
|
||||
|
||||
deps:
|
||||
if [ -d "./client/app" ]; then npm install; fi
|
||||
if [ -d "./client/app" ]; then npm run build; fi
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" *
|
||||
|
||||
upload:
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
pytest $(TEST_ARGS)
|
@ -36,11 +36,18 @@ help() {
|
||||
echo "dev_server -- start Flask development server with debugger and auto reload"
|
||||
echo "create_db -- create database tables"
|
||||
echo "manage -- CLI to manage redash"
|
||||
echo "tests -- run tests"
|
||||
}
|
||||
|
||||
tests() {
|
||||
export REDASH_DATABASE_URL="postgresql://postgres@postgres/tests"
|
||||
exec make test
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
TEST_ARGS=tests/
|
||||
else
|
||||
TEST_ARGS=$@
|
||||
fi
|
||||
exec pytest $TEST_ARGS
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
@ -70,9 +77,11 @@ case "$1" in
|
||||
exec /app/manage.py $*
|
||||
;;
|
||||
tests)
|
||||
tests
|
||||
shift
|
||||
tests $@
|
||||
;;
|
||||
help)
|
||||
shift
|
||||
help
|
||||
;;
|
||||
*)
|
||||
|
8
bin/pack
Executable file
8
bin/pack
Executable file
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
NAME=redash
|
||||
VERSION=$(python ./manage.py version)
|
||||
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$($FULL_VERSION).tar.gz
|
||||
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" *
|
@ -3,6 +3,7 @@ import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib
|
||||
from collections import namedtuple
|
||||
from fnmatch import fnmatch
|
||||
|
||||
@ -81,6 +82,7 @@ def get_latest_release_from_ci():
|
||||
|
||||
tarball_asset = filter(lambda asset: asset['url'].endswith('.tar.gz'), response.json())[0]
|
||||
filename = tarball_asset['pretty_path'].replace('$CIRCLE_ARTIFACTS/', '')
|
||||
filename = os.path.basename(urllib.unquote(filename))
|
||||
version = filename.replace('redash.', '').replace('.tar.gz', '')
|
||||
|
||||
release = Release(version, tarball_asset['url'], filename, '')
|
||||
|
10
circle.yml
10
circle.yml
@ -10,7 +10,8 @@ dependencies:
|
||||
- pip install --upgrade setuptools
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
- make deps
|
||||
- npm install
|
||||
- npm run build
|
||||
cache_directories:
|
||||
- node_modules/
|
||||
test:
|
||||
@ -20,11 +21,8 @@ deployment:
|
||||
github_and_docker:
|
||||
branch: [master, /release.*/]
|
||||
commands:
|
||||
- make pack
|
||||
# Skipping uploads for now, until master is stable.
|
||||
# - make upload
|
||||
#- echo "client/app" >> .dockerignore
|
||||
#- docker pull redash/redash:latest
|
||||
- bin/pack
|
||||
# - python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
|
@ -11,6 +11,7 @@ counter-renderer counter {
|
||||
display: block;
|
||||
font-size: 80px;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
counter-renderer value,
|
||||
|
27
client/app/components/app-view/error-handler.js
Normal file
27
client/app/components/app-view/error-handler.js
Normal file
@ -0,0 +1,27 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export class ErrorHandler {
|
||||
constructor() {
|
||||
this.logToConsole = true;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
process(error) {
|
||||
if (!(error instanceof Error)) {
|
||||
if (error.status && error.data) {
|
||||
switch (error.status) {
|
||||
case 403: error = new Error(''); break;
|
||||
default: error = new Error(error.data.message); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.error = error;
|
||||
if (this.logToConsole) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
50
client/app/components/app-view/index.js
Normal file
50
client/app/components/app-view/index.js
Normal file
@ -0,0 +1,50 @@
|
||||
import debug from 'debug';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
import template from './template.html';
|
||||
|
||||
const logger = debug('redash:app-view');
|
||||
|
||||
const handler = new ErrorHandler();
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.factory('$exceptionHandler', () => function exceptionHandler(exception) {
|
||||
handler.process(exception);
|
||||
});
|
||||
|
||||
ngModule.component('appView', {
|
||||
template,
|
||||
controller($rootScope, $route, Auth) {
|
||||
this.showHeaderAndFooter = false;
|
||||
|
||||
this.handler = handler;
|
||||
|
||||
$rootScope.$on('$routeChangeStart', (event, route) => {
|
||||
if (route.$$route.authenticated) {
|
||||
// For routes that need authentication, check if session is already
|
||||
// loaded, and load it if not.
|
||||
logger('Requested authenticated route: ', route);
|
||||
if (Auth.isAuthenticated()) {
|
||||
this.showHeaderAndFooter = true;
|
||||
} else {
|
||||
event.preventDefault();
|
||||
// Auth.requireSession resolves only if session loaded
|
||||
Auth.requireSession().then(() => {
|
||||
this.showHeaderAndFooter = true;
|
||||
$route.reload();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.showHeaderAndFooter = false;
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
handler.reset();
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeError', (event, current, previous, rejection) => {
|
||||
throw rejection;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
6
client/app/components/app-view/template.html
Normal file
6
client/app/components/app-view/template.html
Normal file
@ -0,0 +1,6 @@
|
||||
<app-header ng-if="$ctrl.showHeaderAndFooter"></app-header>
|
||||
<div ng-if="$ctrl.handler.error" class="container-fluid">
|
||||
<div class="alert alert-danger">{{ $ctrl.handler.error.message }}</div>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.handler.error" ng-view></div>
|
||||
<footer ng-if="$ctrl.showHeaderAndFooter"></footer>
|
@ -1,19 +0,0 @@
|
||||
export default function init(ngModule) {
|
||||
ngModule.component('routeStatus', {
|
||||
template: '<overlay ng-if="$ctrl.permissionDenied">You do not have permission to load this page.',
|
||||
|
||||
controller($rootScope) {
|
||||
this.permissionDenied = false;
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', () => {
|
||||
this.permissionDenied = false;
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeError', (event, current, previous, rejection) => {
|
||||
if (rejection.status === 403) {
|
||||
this.permissionDenied = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
@ -4,13 +4,11 @@
|
||||
|
||||
<div class="bg-white tiled">
|
||||
<ul class="tab-nav">
|
||||
<li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li>
|
||||
<li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>
|
||||
<li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li>
|
||||
<li ng-class="{'active': snippetsPage }" ng-if="showQuerySnippetsLink"><a href="query_snippets">Query Snippets</a></li>
|
||||
<li ng-class="{'active': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
|
||||
</ul>
|
||||
<li ng-class="{'active': $ctrl.isActive(menuItem.pathPrefix)}" ng-if="$ctrl.isAvailable(menuItem.permission)" ng-repeat="menuItem in $ctrl.settingsMenu.menus">
|
||||
<a href="{{menuItem.path}}">{{menuItem.title}}</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
<div class="p-15">
|
||||
<div ng-transclude>
|
||||
|
||||
|
@ -1,23 +1,15 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import startsWith from 'underscore.string/startsWith';
|
||||
import template from './settings-screen.html';
|
||||
|
||||
export default function init(ngModule) {
|
||||
ngModule.directive('settingsScreen', $location => ({
|
||||
restrict: 'E',
|
||||
ngModule.component('settingsScreen', {
|
||||
transclude: true,
|
||||
template,
|
||||
controller($scope, currentUser) {
|
||||
$scope.usersPage = startsWith($location.path(), '/users');
|
||||
$scope.groupsPage = startsWith($location.path(), '/groups');
|
||||
$scope.dsPage = startsWith($location.path(), '/data_sources');
|
||||
$scope.destinationsPage = startsWith($location.path(), '/destinations');
|
||||
$scope.snippetsPage = startsWith($location.path(), '/query_snippets');
|
||||
|
||||
$scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
$scope.showUsersLink = currentUser.hasPermission('list_users');
|
||||
$scope.showDsLink = currentUser.hasPermission('admin');
|
||||
$scope.showDestinationsLink = currentUser.hasPermission('admin');
|
||||
$scope.showQuerySnippetsLink = currentUser.hasPermission('create_query');
|
||||
controller($location, currentUser) {
|
||||
this.settingsMenu = settingsMenu;
|
||||
this.isActive = prefix => startsWith($location.path(), prefix);
|
||||
this.isAvailable = permission => currentUser.hasPermission(permission);
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ import 'brace';
|
||||
import 'angular-ui-ace';
|
||||
import 'angular-resizable';
|
||||
import ngGridster from 'angular-gridster';
|
||||
import { each } from 'underscore';
|
||||
import { each, isFunction } from 'underscore';
|
||||
|
||||
import '@/lib/sortable';
|
||||
|
||||
@ -57,7 +57,7 @@ function registerAll(context) {
|
||||
.map(context)
|
||||
.map(module => module.default);
|
||||
|
||||
return modules.map(f => f(ngModule));
|
||||
return modules.filter(isFunction).map(f => f(ngModule));
|
||||
}
|
||||
|
||||
|
||||
@ -91,11 +91,6 @@ function registerPages() {
|
||||
ngModule.config(($routeProvider) => {
|
||||
each(routes, (route, path) => {
|
||||
logger('Registering route: %s', path);
|
||||
// This is a workaround, to make sure app-header and footer are loaded only
|
||||
// for the authenticated routes.
|
||||
// We should look into switching to ui-router, that has built in support for
|
||||
// such things.
|
||||
route.template = `<app-header></app-header><route-status></route-status>${route.template}<footer></footer>`;
|
||||
route.authenticated = true;
|
||||
$routeProvider.when(path, route);
|
||||
});
|
||||
|
@ -92,7 +92,7 @@ export function notEmpty(collection) {
|
||||
return !isEmpty(collection);
|
||||
}
|
||||
|
||||
export function showError(field, form) {
|
||||
return (field.$touched && field.$invalid) || form.$submitted;
|
||||
export function showError(field) {
|
||||
return field.$touched && field.$invalid;
|
||||
}
|
||||
|
||||
|
@ -13,7 +13,7 @@
|
||||
|
||||
<body>
|
||||
<section>
|
||||
<div ng-view></div>
|
||||
<app-view></app-view>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
15
client/app/lib/settings-menu.js
Normal file
15
client/app/lib/settings-menu.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { sortBy } from 'underscore';
|
||||
|
||||
const settingsMenu = {
|
||||
menus: [],
|
||||
add(menu) {
|
||||
if (menu.pathPrefix === undefined) {
|
||||
menu.pathPrefix = `/${menu.path}`;
|
||||
}
|
||||
|
||||
this.menus.push(menu);
|
||||
this.menus = sortBy(this.menus, 'order');
|
||||
},
|
||||
};
|
||||
|
||||
export default settingsMenu;
|
@ -13,7 +13,7 @@
|
||||
|
||||
<body>
|
||||
<section>
|
||||
<div ng-view></div>
|
||||
<app-view></app-view>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -5,10 +5,9 @@ import template from './dashboard-list.html';
|
||||
import './dashboard-list.css';
|
||||
|
||||
|
||||
function DashboardListCtrl(Dashboard, $location, clientConfig) {
|
||||
function DashboardListCtrl(Dashboard, $location) {
|
||||
const TAGS_REGEX = /(^([\w\s]|[^\u0000-\u007F])+):|(#([\w-]|[^\u0000-\u007F])+)/ig;
|
||||
|
||||
this.logoUrl = clientConfig.logoUrl;
|
||||
const page = parseInt($location.search().page || 1, 10);
|
||||
|
||||
this.defaultOptions = {};
|
||||
|
@ -164,12 +164,16 @@ function DashboardCtrl(
|
||||
this.dashboard = Dashboard.get({ slug: $routeParams.dashboardSlug }, (dashboard) => {
|
||||
Events.record('view', 'dashboard', dashboard.id);
|
||||
renderDashboard(dashboard, force);
|
||||
}, () => {
|
||||
// error...
|
||||
// try again. we wrap loadDashboard with throttle so it doesn't happen too often.
|
||||
// we might want to consider exponential backoff and also move this as a general
|
||||
// solution in $http/$resource for all AJAX calls.
|
||||
this.loadDashboard();
|
||||
}, (error) => {
|
||||
const statusGroup = Math.floor(error.status / 100);
|
||||
if (statusGroup === 5) {
|
||||
// recoverable errors - all 5** (server is temporarily unavailable
|
||||
// for some reason, but it should get up soon).
|
||||
this.loadDashboard();
|
||||
} else {
|
||||
// all kind of 4** errors are not recoverable, so just display them
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
|
@ -95,6 +95,7 @@
|
||||
top: 15px;
|
||||
right: 10px;
|
||||
bottom: 15px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import template from './list.html';
|
||||
|
||||
function DataSourcesCtrl($scope, $location, currentUser, Events, DataSource) {
|
||||
@ -7,6 +8,13 @@ function DataSourcesCtrl($scope, $location, currentUser, Events, DataSource) {
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'admin',
|
||||
title: 'Data Sources',
|
||||
path: 'data_sources',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
ngModule.controller('DataSourcesCtrl', DataSourcesCtrl);
|
||||
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import template from './list.html';
|
||||
|
||||
function DestinationsCtrl($scope, $location, toastr, currentUser, Events, Destination) {
|
||||
@ -7,6 +8,12 @@ function DestinationsCtrl($scope, $location, toastr, currentUser, Events, Destin
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'admin',
|
||||
title: 'Alert Destinations',
|
||||
path: 'destinations',
|
||||
});
|
||||
|
||||
ngModule.controller('DestinationsCtrl', DestinationsCtrl);
|
||||
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import { Paginator } from '@/lib/pagination';
|
||||
import template from './list.html';
|
||||
|
||||
@ -23,6 +24,13 @@ function GroupsCtrl($scope, $uibModal, currentUser, Events, Group) {
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'list_users',
|
||||
title: 'Groups',
|
||||
path: 'groups',
|
||||
order: 3,
|
||||
});
|
||||
|
||||
ngModule.controller('GroupsCtrl', GroupsCtrl);
|
||||
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import { Paginator } from '@/lib/pagination';
|
||||
import template from './list.html';
|
||||
|
||||
@ -11,6 +12,12 @@ function SnippetsCtrl($location, currentUser, Events, QuerySnippet) {
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'create_query',
|
||||
title: 'Query Snippets',
|
||||
path: 'query_snippets',
|
||||
});
|
||||
|
||||
ngModule.component('snippetsListPage', {
|
||||
template,
|
||||
controller: SnippetsCtrl,
|
||||
|
52
client/app/pages/settings/organization.html
Normal file
52
client/app/pages/settings/organization.html
Normal file
@ -0,0 +1,52 @@
|
||||
<settings-screen>
|
||||
<div class="row m-t-5">
|
||||
<div class="col-md-12">
|
||||
<h3>General</h3>
|
||||
<p>
|
||||
<label>
|
||||
Date Format
|
||||
</label>
|
||||
<select class="form-control" ng-model="$ctrl.settings.date_format" ng-change="$ctrl.update('date_format')">
|
||||
<option value="DD/MM/YY">DD/MM/YY</option>
|
||||
<option value="MM/DD/YY">MM/DD/YY</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||
</select>
|
||||
</p>
|
||||
<h3>Authentication</h3>
|
||||
<p>
|
||||
<label>
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_password_login_enabled"
|
||||
ng-change="$ctrl.update('auth_password_login_enabled')" accesskey="tab">
|
||||
Password Login Enabled
|
||||
</label>
|
||||
</p>
|
||||
|
||||
<h4>SAML</h4>
|
||||
<p>
|
||||
<label>
|
||||
<input name="input" type="checkbox" ng-model="$ctrl.settings.auth_saml_enabled"
|
||||
ng-change="$ctrl.update('auth_saml_enabled')" accesskey="tab">
|
||||
SAML Enabled
|
||||
</label>
|
||||
|
||||
<div ng-show="$ctrl.settings.auth_saml_enabled">
|
||||
<div class="form-group">
|
||||
<label>SAML Metadata URL</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_metadata_url" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_metadata_url')">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SAML Entity ID</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_entity_id" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_metadata_entity_id')">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>SAML NameID Format</label>
|
||||
<input name="input" type="string" class="form-control" ng-model="$ctrl.settings.auth_saml_nameid_format" accesskey="tab"
|
||||
ng-change="$ctrl.update('auth_saml_metadata_nameid_format')">
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
41
client/app/pages/settings/organization.js
Normal file
41
client/app/pages/settings/organization.js
Normal file
@ -0,0 +1,41 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import template from './organization.html';
|
||||
|
||||
function OrganizationSettingsCtrl($http, toastr, Events) {
|
||||
Events.record('view', 'page', 'org_settings');
|
||||
|
||||
this.settings = {};
|
||||
$http.get('api/settings/organization').then((response) => {
|
||||
this.settings = response.data.settings;
|
||||
});
|
||||
|
||||
this.update = (key) => {
|
||||
$http.post('api/settings/organization', { [key]: this.settings[key] }).then((response) => {
|
||||
this.settings = response.data.settings;
|
||||
toastr.success('Settings changes saved.');
|
||||
}).catch(() => {
|
||||
toastr.error('Failed saving changes.');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'admin',
|
||||
title: 'Settings',
|
||||
path: 'settings/organization',
|
||||
});
|
||||
|
||||
ngModule.component('organizationSettingsPage', {
|
||||
template,
|
||||
controller: OrganizationSettingsCtrl,
|
||||
});
|
||||
|
||||
return {
|
||||
'/settings/organization': {
|
||||
template: '<organization-settings-page></organization-settings-page>',
|
||||
title: 'Organization Settings',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import settingsMenu from '@/lib/settings-menu';
|
||||
import { Paginator } from '@/lib/pagination';
|
||||
import template from './list.html';
|
||||
|
||||
@ -12,6 +13,14 @@ function UsersCtrl(currentUser, Events, User) {
|
||||
}
|
||||
|
||||
export default function init(ngModule) {
|
||||
settingsMenu.add({
|
||||
permission: 'list_users',
|
||||
title: 'Users',
|
||||
path: 'users',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
|
||||
ngModule.component('usersListPage', {
|
||||
controller: UsersCtrl,
|
||||
template,
|
||||
|
@ -1,14 +1,14 @@
|
||||
<settings-screen>
|
||||
<email-settings-warning function="'invite emails'"></email-settings-warning>
|
||||
<form class="form" name="$ctrl.userForm" ng-submit="saveUser()" novalidate>
|
||||
<div class="form-group required" ng-class="{ 'has-error': ($ctrl.userForm.email | showError:$ctrl.userForm )}">
|
||||
<div class="form-group required" ng-class="{ 'has-error': ($ctrl.userForm.name | showError )}">
|
||||
<label class="control-label">Name</label>
|
||||
<input type="text" name="name" class="form-control" ng-model="user.name" ng-disabled="user.created" required/ autofocus>
|
||||
|
||||
<error-messages input="$ctrl.userForm.name" form="$ctrl.userForm"></error-messages>
|
||||
</div>
|
||||
|
||||
<div class="form-group required" ng-class="{ 'has-error': ($ctrl.userForm.email | showError:$ctrl.userForm ) }">
|
||||
<div class="form-group required" ng-class="{ 'has-error': ($ctrl.userForm.email | showError ) }">
|
||||
<label class="control-label">Email</label>
|
||||
<input name="email" type="email" class="form-control" ng-model="user.email" ng-disabled="user.created" required/>
|
||||
|
||||
|
@ -12,12 +12,12 @@
|
||||
<hr>
|
||||
|
||||
<form class="form" name="userSettingsForm" ng-submit="updateUser(userSettingsForm)" novalidate>
|
||||
<div class="form-group" ng-class="{ 'has-error': (userSettingsForm.name | showError:userSettingsForm )}">
|
||||
<div class="form-group" ng-class="{ 'has-error': (userSettingsForm.name | showError )}">
|
||||
<label class="control-label" for="name" >Name</label>
|
||||
<input name="name" id="name" type="text" class="form-control" ng-model="user.name" required/>
|
||||
<error-messages input="userSettingsForm.name" form="userSettingsForm"></error-messages>
|
||||
</div>
|
||||
<div class="form-group" ng-class="{ 'has-error': (userSettingsForm.email | showError:userSettingsForm )}">
|
||||
<div class="form-group" ng-class="{ 'has-error': (userSettingsForm.email | showError )}">
|
||||
<label class="control-label" for="email" >Email</label>
|
||||
<input name="email" id="email" type="email" class="form-control" ng-model="user.email" required/>
|
||||
<error-messages input="userSettingsForm.email" form="userSettingsForm"></error-messages>
|
||||
|
@ -66,6 +66,23 @@ function AuthService($window, $location, $q, $http) {
|
||||
getApiKey() {
|
||||
return this.apiKey;
|
||||
},
|
||||
requireSession() {
|
||||
logger('Requested authentication');
|
||||
if (Auth.isAuthenticated()) {
|
||||
return $q.when(getLocalSessionData());
|
||||
}
|
||||
return Auth.loadSession().then(() => {
|
||||
if (Auth.isAuthenticated()) {
|
||||
logger('Loaded session');
|
||||
return getLocalSessionData();
|
||||
}
|
||||
logger('Need to login, redirecting');
|
||||
this.login();
|
||||
}).catch(() => {
|
||||
logger('Need to login, redirecting');
|
||||
this.login();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return Auth;
|
||||
@ -112,21 +129,4 @@ export default function init(ngModule) {
|
||||
ngModule.config(($httpProvider) => {
|
||||
$httpProvider.interceptors.push('apiKeyHttpInterceptor');
|
||||
});
|
||||
|
||||
ngModule.run(($location, $window, $rootScope, $route, Auth) => {
|
||||
$rootScope.$on('$routeChangeStart', (event, to) => {
|
||||
if (to.authenticated && !Auth.isAuthenticated()) {
|
||||
logger('Requested authenticated route: ', to);
|
||||
event.preventDefault();
|
||||
// maybe we only miss the session? try to load session
|
||||
Auth.loadSession().then(() => {
|
||||
logger('Loaded session');
|
||||
$route.reload();
|
||||
}).catch(() => {
|
||||
logger('Need to login, redirecting');
|
||||
Auth.login();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ function getRowNumber(index, size) {
|
||||
return size + index;
|
||||
}
|
||||
|
||||
function CounterRenderer() {
|
||||
function CounterRenderer($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: counterTemplate,
|
||||
@ -97,6 +97,10 @@ function CounterRenderer() {
|
||||
$scope.stringSuffix = null;
|
||||
}
|
||||
}
|
||||
|
||||
$timeout(() => {
|
||||
$scope.handleResize();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('visualization.options', refreshData, true);
|
||||
|
@ -8,7 +8,6 @@ from flask import redirect, request, jsonify, url_for
|
||||
|
||||
from redash import models, settings
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash.authentication import google_oauth, saml_auth, remote_user_auth, ldap_auth
|
||||
from redash.tasks import record_event
|
||||
|
||||
login_manager = LoginManager()
|
||||
@ -140,6 +139,8 @@ def redirect_to_login():
|
||||
|
||||
|
||||
def setup_authentication(app):
|
||||
from redash.authentication import google_oauth, saml_auth, remote_user_auth, ldap_auth
|
||||
|
||||
login_manager.init_app(app)
|
||||
login_manager.anonymous_user = models.AnonymousUser
|
||||
|
||||
|
@ -1,52 +1,32 @@
|
||||
import logging
|
||||
import requests
|
||||
from flask import redirect, url_for, Blueprint, request
|
||||
from redash.authentication.google_oauth import create_and_login_user
|
||||
from redash.authentication.org_resolving import current_org
|
||||
from redash import settings
|
||||
from redash.handlers.base import org_scoped_rule
|
||||
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity
|
||||
from saml2.client import Saml2Client
|
||||
from saml2.config import Config as Saml2Config
|
||||
from saml2.saml import NAMEID_FORMAT_TRANSIENT
|
||||
|
||||
logger = logging.getLogger('saml_auth')
|
||||
|
||||
blueprint = Blueprint('saml_auth', __name__)
|
||||
|
||||
|
||||
def get_saml_client():
|
||||
def get_saml_client(org):
|
||||
"""
|
||||
Return SAML configuration.
|
||||
|
||||
The configuration is a hash for use by saml2.config.Config
|
||||
"""
|
||||
if settings.SAML_CALLBACK_SERVER_NAME:
|
||||
acs_url = settings.SAML_CALLBACK_SERVER_NAME + url_for("saml_auth.idp_initiated")
|
||||
else:
|
||||
acs_url = url_for("saml_auth.idp_initiated", _external=True)
|
||||
|
||||
# NOTE:
|
||||
# Ideally, this should fetch the metadata and pass it to
|
||||
# PySAML2 via the "inline" metadata type.
|
||||
# However, this method doesn't seem to work on PySAML2 v2.4.0
|
||||
#
|
||||
# SAML metadata changes very rarely. On a production system,
|
||||
# this data should be cached as approprate for your production system.
|
||||
if settings.SAML_METADATA_URL != "":
|
||||
rv = requests.get(settings.SAML_METADATA_URL)
|
||||
import tempfile
|
||||
tmp = tempfile.NamedTemporaryFile()
|
||||
f = open(tmp.name, 'w')
|
||||
f.write(rv.text)
|
||||
f.close()
|
||||
metadata_path = tmp.name
|
||||
else:
|
||||
metadata_path = settings.SAML_LOCAL_METADATA_PATH
|
||||
metadata_url = org.get_setting("auth_saml_metadata_url")
|
||||
entity_id = org.get_setting("auth_saml_entity_id")
|
||||
acs_url = url_for("saml_auth.idp_initiated", _external=True)
|
||||
|
||||
saml_settings = {
|
||||
'metadata': {
|
||||
# 'inline': metadata,
|
||||
"local": [metadata_path]
|
||||
"remote": [{
|
||||
"url": metadata_url
|
||||
}]
|
||||
},
|
||||
'service': {
|
||||
'sp': {
|
||||
@ -68,22 +48,25 @@ def get_saml_client():
|
||||
},
|
||||
},
|
||||
}
|
||||
if settings.SAML_ENTITY_ID != "":
|
||||
saml_settings['entityid'] = settings.SAML_ENTITY_ID
|
||||
|
||||
spConfig = Saml2Config()
|
||||
spConfig.load(saml_settings)
|
||||
spConfig.allow_unknown_attributes = True
|
||||
saml_client = Saml2Client(config=spConfig)
|
||||
if settings.SAML_METADATA_URL != "":
|
||||
tmp.close()
|
||||
if entity_id is not None and entity_id != "":
|
||||
saml_settings['entityid'] = entity_id
|
||||
|
||||
sp_config = Saml2Config()
|
||||
sp_config.load(saml_settings)
|
||||
sp_config.allow_unknown_attributes = True
|
||||
saml_client = Saml2Client(config=sp_config)
|
||||
|
||||
return saml_client
|
||||
|
||||
|
||||
@blueprint.route("/saml/callback", methods=['POST'])
|
||||
@blueprint.route(org_scoped_rule('/saml/callback'), methods=['POST'])
|
||||
def idp_initiated():
|
||||
saml_client = get_saml_client()
|
||||
if not current_org.get_setting("auth_saml_enabled"):
|
||||
logger.error("SAML Login is not enabled")
|
||||
return redirect(url_for('redash.index'))
|
||||
|
||||
saml_client = get_saml_client(current_org)
|
||||
authn_response = saml_client.parse_authn_request_response(
|
||||
request.form['SAMLResponse'],
|
||||
entity.BINDING_HTTP_POST)
|
||||
@ -106,18 +89,18 @@ def idp_initiated():
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@blueprint.route("/saml/login")
|
||||
@blueprint.route(org_scoped_rule("/saml/login"))
|
||||
def sp_initiated():
|
||||
if not settings.SAML_METADATA_URL and not settings.SAML_LOCAL_METADATA_PATH:
|
||||
logger.error("Cannot invoke saml endpoint without metadata url in settings.")
|
||||
if not current_org.get_setting("auth_saml_enabled"):
|
||||
logger.error("SAML Login is not enabled")
|
||||
return redirect(url_for('redash.index'))
|
||||
|
||||
saml_client = get_saml_client()
|
||||
if settings.SAML_NAMEID_FORMAT != "":
|
||||
nameid_format = settings.SAML_NAMEID_FORMAT
|
||||
else:
|
||||
saml_client = get_saml_client(current_org)
|
||||
nameid_format = current_org.get_setting('auth_saml_nameid_format')
|
||||
if nameid_format is None or nameid_format == "":
|
||||
nameid_format = NAMEID_FORMAT_TRANSIENT
|
||||
reqid, info = saml_client.prepare_for_authenticate(nameid_format=nameid_format)
|
||||
|
||||
_, info = saml_client.prepare_for_authenticate(nameid_format=nameid_format)
|
||||
|
||||
redirect_url = None
|
||||
# Select the IdP URL to send the AuthN request to
|
||||
@ -125,6 +108,7 @@ def sp_initiated():
|
||||
if key is 'Location':
|
||||
redirect_url = value
|
||||
response = redirect(redirect_url, code=302)
|
||||
|
||||
# NOTE:
|
||||
# I realize I _technically_ don't need to set Cache-Control or Pragma:
|
||||
# http://stackoverflow.com/a/5494469
|
||||
|
@ -96,6 +96,54 @@ def create(email, name, groups, is_admin=False, google_auth=False,
|
||||
exit(1)
|
||||
|
||||
|
||||
@manager.command()
|
||||
@argument('email')
|
||||
@argument('name')
|
||||
@option('--org', 'organization', default='default',
|
||||
help="The organization the root user belongs to (leave blank for 'default').")
|
||||
@option('--google', 'google_auth', is_flag=True,
|
||||
default=False, help="user uses Google Auth to login")
|
||||
@option('--password', 'password', default=None,
|
||||
help="Password for root user who don't use Google Auth "
|
||||
"(leave blank for prompt).")
|
||||
def create_root(email, name, google_auth=False, password=None, organization='default'):
|
||||
"""
|
||||
Create root user.
|
||||
"""
|
||||
print("Creating root user (%s, %s) in organization %s..." % (email, name, organization))
|
||||
print("Login with Google Auth: %r\n" % google_auth)
|
||||
|
||||
user = models.User.query.filter(models.User.email == email).first()
|
||||
if user is not None:
|
||||
print("User [%s] is already exists." % email)
|
||||
exit(1)
|
||||
|
||||
slug = 'default'
|
||||
default_org = models.Organization.query.filter(models.Organization.slug == slug).first()
|
||||
if default_org is None:
|
||||
default_org = models.Organization(name=organization, slug=slug, settings={})
|
||||
|
||||
admin_group = models.Group(name='admin', permissions=['admin', 'super_admin'],
|
||||
org=default_org, type=models.Group.BUILTIN_GROUP)
|
||||
default_group = models.Group(name='default', permissions=models.Group.DEFAULT_PERMISSIONS,
|
||||
org=default_org, type=models.Group.BUILTIN_GROUP)
|
||||
|
||||
models.db.session.add_all([default_org, admin_group, default_group])
|
||||
models.db.session.commit()
|
||||
|
||||
user = models.User(org=default_org, email=email, name=name,
|
||||
group_ids=[admin_group.id, default_group.id])
|
||||
if not google_auth:
|
||||
user.hash_password(password)
|
||||
|
||||
try:
|
||||
models.db.session.add(user)
|
||||
models.db.session.commit()
|
||||
except Exception as e:
|
||||
print("Failed creating root user: %s" % e.message)
|
||||
exit(1)
|
||||
|
||||
|
||||
@manager.command()
|
||||
@argument('email')
|
||||
@option('--org', 'organization', default=None,
|
||||
|
@ -19,6 +19,7 @@ from redash.handlers.groups import GroupListResource, GroupResource, GroupMember
|
||||
GroupDataSourceListResource, GroupDataSourceResource
|
||||
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
|
||||
from redash.handlers.query_snippets import QuerySnippetListResource, QuerySnippetResource
|
||||
from redash.handlers.settings import OrganizationSettings
|
||||
|
||||
|
||||
class ApiExt(Api):
|
||||
@ -103,3 +104,5 @@ api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='des
|
||||
|
||||
api.add_org_resource(QuerySnippetResource, '/api/query_snippets/<snippet_id>', endpoint='query_snippet')
|
||||
api.add_org_resource(QuerySnippetListResource, '/api/query_snippets', endpoint='query_snippets')
|
||||
|
||||
api.add_org_resource(OrganizationSettings, '/api/settings/organization', endpoint='organization_settings')
|
||||
|
@ -74,7 +74,7 @@ def reset(token, org_slug=None):
|
||||
|
||||
@routes.route(org_scoped_rule('/forgot'), methods=['GET', 'POST'])
|
||||
def forgot_password(org_slug=None):
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
if not current_org.get_setting('auth_password_login_enabled'):
|
||||
abort(404)
|
||||
|
||||
submitted = False
|
||||
@ -106,10 +106,10 @@ def login(org_slug=None):
|
||||
if current_user.is_authenticated:
|
||||
return redirect(next_path)
|
||||
|
||||
if not settings.PASSWORD_LOGIN_ENABLED:
|
||||
if not current_org.get_setting('auth_password_login_enabled'):
|
||||
if settings.REMOTE_USER_LOGIN_ENABLED:
|
||||
return redirect(url_for("remote_user_auth.login", next=next_path))
|
||||
elif settings.SAML_LOGIN_ENABLED:
|
||||
elif current_org.get_setting('auth_saml_enabled'): # settings.SAML_LOGIN_ENABLED:
|
||||
return redirect(url_for("saml_auth.sp_initiated", next=next_path))
|
||||
elif settings.LDAP_LOGIN_ENABLED:
|
||||
return redirect(url_for("ldap_auth.login", next=next_path))
|
||||
@ -137,7 +137,7 @@ def login(org_slug=None):
|
||||
email=request.form.get('email', ''),
|
||||
show_google_openid=settings.GOOGLE_OAUTH_ENABLED,
|
||||
google_auth_url=google_auth_url,
|
||||
show_saml_login=settings.SAML_LOGIN_ENABLED,
|
||||
show_saml_login=current_org.get_setting('auth_saml_enabled'),
|
||||
show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED,
|
||||
show_ldap_login=settings.LDAP_LOGIN_ENABLED)
|
||||
|
||||
@ -166,7 +166,19 @@ def client_config():
|
||||
else:
|
||||
client_config = {}
|
||||
|
||||
client_config.update(settings.COMMON_CLIENT_CONFIG)
|
||||
date_format = current_org.get_setting('date_format')
|
||||
|
||||
defaults = {
|
||||
'allowScriptsInUserInput': settings.ALLOW_SCRIPTS_IN_USER_INPUT,
|
||||
'showPermissionsControl': settings.FEATURE_SHOW_PERMISSIONS_CONTROL,
|
||||
'allowCustomJSVisualizations': settings.FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS,
|
||||
'autoPublishNamedQueries': settings.FEATURE_AUTO_PUBLISH_NAMED_QUERIES,
|
||||
'dateFormat': date_format,
|
||||
'dateTimeFormat': "{0} HH:mm".format(date_format),
|
||||
'mailSettingsMissing': settings.MAIL_DEFAULT_SENDER is None
|
||||
}
|
||||
|
||||
client_config.update(defaults)
|
||||
client_config.update({
|
||||
'basePath': base_href()
|
||||
})
|
||||
|
52
redash/handlers/settings.py
Normal file
52
redash/handlers/settings.py
Normal file
@ -0,0 +1,52 @@
|
||||
from flask import request
|
||||
|
||||
from redash.models import db
|
||||
from redash.handlers.base import BaseResource
|
||||
from redash.permissions import require_admin
|
||||
from redash.settings.organization import settings as org_settings
|
||||
|
||||
|
||||
def get_settings_with_defaults(defaults, values):
|
||||
settings = {}
|
||||
|
||||
for setting, default_value in defaults.iteritems():
|
||||
current_value = values.get(setting)
|
||||
if current_value is None and default_value is None:
|
||||
continue
|
||||
|
||||
if current_value is None:
|
||||
settings[setting] = default_value
|
||||
else:
|
||||
settings[setting] = current_value
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
class OrganizationSettings(BaseResource):
|
||||
@require_admin
|
||||
def get(self):
|
||||
current_values = self.current_org.settings.get('settings', {})
|
||||
settings = get_settings_with_defaults(org_settings, current_values)
|
||||
|
||||
return {
|
||||
"settings": settings
|
||||
}
|
||||
|
||||
@require_admin
|
||||
def post(self):
|
||||
new_values = request.json
|
||||
|
||||
if self.current_org.settings.get('settings') is None:
|
||||
self.current_org.settings['settings'] = {}
|
||||
|
||||
for k, v in new_values.iteritems():
|
||||
self.current_org.set_setting(k, v)
|
||||
|
||||
db.session.add(self.current_org)
|
||||
db.session.commit()
|
||||
|
||||
settings = get_settings_with_defaults(org_settings, self.current_org.settings['settings'])
|
||||
|
||||
return {
|
||||
"settings": settings
|
||||
}
|
@ -69,6 +69,7 @@ rules = ['/admin/<anything>/<whatever>',
|
||||
'/groups/<pk>/data_sources',
|
||||
'/queries/<query_id>',
|
||||
'/queries/<query_id>/<anything>',
|
||||
'/settings/organization',
|
||||
'/personal']
|
||||
|
||||
register_static_routes(rules)
|
||||
|
@ -24,6 +24,7 @@ from redash.query_runner import (get_configuration_schema_for_query_runner_type,
|
||||
from redash.utils import generate_token, json_dumps
|
||||
from redash.utils.comparators import CaseInsensitiveComparator
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from redash.settings.organization import settings as org_settings
|
||||
from sqlalchemy import distinct, or_
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.event import listens_for
|
||||
@ -32,6 +33,7 @@ from sqlalchemy.inspection import inspect
|
||||
from sqlalchemy.orm import backref, joinedload, object_session, subqueryload
|
||||
from sqlalchemy.orm.exc import NoResultFound # noqa: F401
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
from functools import reduce
|
||||
|
||||
|
||||
@ -314,6 +316,23 @@ class Organization(TimestampMixin, db.Model):
|
||||
def enable(self):
|
||||
self.settings['is_disabled'] = False
|
||||
|
||||
def set_setting(self, key, value):
|
||||
if key not in org_settings:
|
||||
raise KeyError(key)
|
||||
|
||||
self.settings.setdefault('settings', {})
|
||||
self.settings['settings'][key] = value
|
||||
flag_modified(self, 'settings')
|
||||
|
||||
def get_setting(self, key):
|
||||
if key in self.settings.get('settings', {}):
|
||||
return self.settings['settings'][key]
|
||||
|
||||
if key in org_settings:
|
||||
return org_settings[key]
|
||||
|
||||
raise KeyError(key)
|
||||
|
||||
@property
|
||||
def admin_group(self):
|
||||
return self.groups.filter(Group.name == 'admin', Group.type == Group.BUILTIN_GROUP).first()
|
||||
|
@ -228,10 +228,18 @@ class Redshift(PostgreSQL):
|
||||
def _get_tables(self, schema):
|
||||
# Use svv_columns to include internal & external (Spectrum) tables and views data for Redshift
|
||||
# http://docs.aws.amazon.com/redshift/latest/dg/r_SVV_COLUMNS.html
|
||||
# Use PG_GET_LATE_BINDING_VIEW_COLS to include schema for late binding views data for Redshift
|
||||
# http://docs.aws.amazon.com/redshift/latest/dg/PG_GET_LATE_BINDING_VIEW_COLS.html
|
||||
query = """
|
||||
SELECT DISTINCT table_name,table_schema, column_name
|
||||
SELECT DISTINCT table_name, table_schema, column_name
|
||||
FROM svv_columns
|
||||
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema');
|
||||
WHERE table_schema NOT IN ('pg_internal','pg_catalog','information_schema')
|
||||
UNION ALL
|
||||
SELECT DISTINCT view_name::varchar AS table_name,
|
||||
view_schema::varchar AS table_schema,
|
||||
col_name::varchar AS column_name
|
||||
FROM pg_get_late_binding_view_cols()
|
||||
cols(view_schema name, view_name name, col_name name, col_type varchar, col_num int);
|
||||
"""
|
||||
|
||||
self._get_definitions(schema, query)
|
||||
|
@ -86,7 +86,7 @@ def create_table(connection, table_name, query_results):
|
||||
safe_columns = [fix_column_name(column) for column in columns]
|
||||
|
||||
column_list = ", ".join(safe_columns)
|
||||
create_table = "CREATE TABLE {table_name} ({column_list})".format(
|
||||
create_table = u"CREATE TABLE {table_name} ({column_list})".format(
|
||||
table_name=table_name, column_list=column_list)
|
||||
logger.debug("CREATE TABLE query: %s", create_table)
|
||||
connection.execute(create_table)
|
||||
|
@ -1,52 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import urlparse
|
||||
|
||||
from funcy import distinct, remove
|
||||
|
||||
|
||||
def parse_db_url(url):
|
||||
url_parts = urlparse.urlparse(url)
|
||||
connection = {'threadlocals': True}
|
||||
|
||||
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 set_from_string(str):
|
||||
return set(array_from_string(str))
|
||||
|
||||
|
||||
def parse_boolean(str):
|
||||
return json.loads(str.lower())
|
||||
|
||||
|
||||
def int_or_none(value):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
return int(value)
|
||||
from .helpers import parse_db_url, fix_assets_path, array_from_string, parse_boolean, int_or_none, set_from_string
|
||||
|
||||
|
||||
def all_settings():
|
||||
@ -59,10 +14,6 @@ def all_settings():
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
NAME = os.environ.get('REDASH_NAME', 'Redash')
|
||||
LOGO_URL = os.environ.get('REDASH_LOGO_URL', '/images/redash_icon_small.png')
|
||||
|
||||
REDIS_URL = os.environ.get('REDASH_REDIS_URL', os.environ.get('REDIS_URL', "redis://localhost:6379/0"))
|
||||
PROXIES_COUNT = int(os.environ.get('REDASH_PROXIES_COUNT', "1"))
|
||||
|
||||
@ -92,7 +43,6 @@ QUERY_RESULTS_CLEANUP_MAX_AGE = int(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP
|
||||
SCHEMAS_REFRESH_SCHEDULE = int(os.environ.get("REDASH_SCHEMAS_REFRESH_SCHEDULE", 30))
|
||||
|
||||
AUTH_TYPE = os.environ.get("REDASH_AUTH_TYPE", "api_key")
|
||||
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "true"))
|
||||
ENFORCE_HTTPS = parse_boolean(os.environ.get("REDASH_ENFORCE_HTTPS", "false"))
|
||||
INVITATION_TOKEN_MAX_AGE = int(os.environ.get("REDASH_INVITATION_TOKEN_MAX_AGE", 60 * 60 * 24 * 7))
|
||||
|
||||
@ -102,13 +52,6 @@ GOOGLE_CLIENT_ID = os.environ.get("REDASH_GOOGLE_CLIENT_ID", "")
|
||||
GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
|
||||
GOOGLE_OAUTH_ENABLED = GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
|
||||
|
||||
SAML_ENTITY_ID = os.environ.get("REDASH_SAML_ENTITY_ID", "")
|
||||
SAML_METADATA_URL = os.environ.get("REDASH_SAML_METADATA_URL", "")
|
||||
SAML_LOCAL_METADATA_PATH = os.environ.get("REDASH_SAML_LOCAL_METADATA_PATH", "")
|
||||
SAML_LOGIN_ENABLED = SAML_METADATA_URL != "" or SAML_LOCAL_METADATA_PATH != ""
|
||||
SAML_NAMEID_FORMAT = os.environ.get("REDASH_SAML_NAMEID_FORMAT", "")
|
||||
SAML_CALLBACK_SERVER_NAME = os.environ.get("REDASH_SAML_CALLBACK_SERVER_NAME", "")
|
||||
|
||||
# Enables the use of an externally-provided and trusted remote user via an HTTP
|
||||
# header. The "user" must be an email address.
|
||||
#
|
||||
@ -129,17 +72,19 @@ SAML_CALLBACK_SERVER_NAME = os.environ.get("REDASH_SAML_CALLBACK_SERVER_NAME", "
|
||||
# user out. Doing so could be done with further work, but usually it's
|
||||
# unnecessary.
|
||||
#
|
||||
# If you also set REDASH_PASSWORD_LOGIN_ENABLED to false, then your
|
||||
# authentication will be seamless. Otherwise a link will be presented on the
|
||||
# login page to trigger remote user auth.
|
||||
# If you also set the organization setting auth_password_login_enabled to false,
|
||||
# then your authentication will be seamless. Otherwise a link will be presented
|
||||
# on the login page to trigger remote user auth.
|
||||
REMOTE_USER_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_REMOTE_USER_LOGIN_ENABLED", "false"))
|
||||
REMOTE_USER_HEADER = os.environ.get("REDASH_REMOTE_USER_HEADER", "X-Forwarded-Remote-User")
|
||||
|
||||
# If REDASH_PASSWORD_LOGIN_ENABLED is not false, then users will still be able to login through Redash instead of the LDAP server
|
||||
# If the organization setting auth_password_login_enabled is not false, then users will still be
|
||||
# able to login through Redash instead of the LDAP server
|
||||
LDAP_LOGIN_ENABLED = parse_boolean(os.environ.get('REDASH_LDAP_LOGIN_ENABLED', 'false'))
|
||||
# The LDAP directory address (ex. ldap://10.0.10.1:389)
|
||||
LDAP_HOST_URL = os.environ.get('REDASH_LDAP_URL', None)
|
||||
# The DN & password used to connect to LDAP to determine the identity of the user being authenticated. For AD this should be "org\\user".
|
||||
# The DN & password used to connect to LDAP to determine the identity of the user being authenticated.
|
||||
# For AD this should be "org\\user".
|
||||
LDAP_BIND_DN = os.environ.get('REDASH_LDAP_BIND_DN', None)
|
||||
LDAP_BIND_DN_PASSWORD = os.environ.get('REDASH_LDAP_BIND_DN_PASSWORD', '')
|
||||
# AD/LDAP email and display name keys
|
||||
@ -150,13 +95,13 @@ LDAP_CUSTOM_USERNAME_PROMPT = os.environ.get('REDASH_LDAP_CUSTOM_USERNAME_PROMPT
|
||||
# LDAP Search DN TEMPLATE (for AD this should be "(sAMAccountName=%(username)s)"")
|
||||
LDAP_SEARCH_TEMPLATE = os.environ.get('REDASH_LDAP_SEARCH_TEMPLATE', '(cn=%(username)s)')
|
||||
# The schema to bind to (ex. cn=users,dc=ORG,dc=local)
|
||||
LDAP_SEARCH_DN = os.environ.get('REDASH_SEARCH_DN', None)
|
||||
LDAP_SEARCH_DN = os.environ.get('REDASH_LDAP_SEARCH_DN', os.environ.get('REDASH_SEARCH_DN'))
|
||||
|
||||
|
||||
# Usually it will be a single path, but we allow to specify additional ones to override the default assets. Only the
|
||||
# last one will be used for Flask templates.
|
||||
STATIC_ASSETS_PATHS = [fix_assets_path(path) for path in os.environ.get("REDASH_STATIC_ASSETS_PATH", "../client/dist/").split(',')]
|
||||
STATIC_ASSETS_PATHS.append(fix_assets_path('./static/'))
|
||||
STATIC_ASSETS_PATHS.append(fix_assets_path('static/'))
|
||||
|
||||
JOB_EXPIRY_TIME = int(os.environ.get("REDASH_JOB_EXPIRY_TIME", 3600 * 12))
|
||||
COOKIE_SECRET = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
|
||||
@ -259,7 +204,6 @@ ALLOW_SCRIPTS_IN_USER_INPUT = parse_boolean(os.environ.get("REDASH_ALLOW_SCRIPTS
|
||||
DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
|
||||
|
||||
# Features:
|
||||
FEATURE_ALLOW_ALL_TO_EDIT_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_ALLOW_ALL_TO_EDIT", "true"))
|
||||
VERSION_CHECK = parse_boolean(os.environ.get("REDASH_VERSION_CHECK", "true"))
|
||||
FEATURE_DISABLE_REFRESH_QUERIES = parse_boolean(os.environ.get("REDASH_FEATURE_DISABLE_REFRESH_QUERIES", "false"))
|
||||
FEATURE_SHOW_QUERY_RESULTS_COUNT = parse_boolean(os.environ.get("REDASH_FEATURE_SHOW_QUERY_RESULTS_COUNT", "true"))
|
||||
@ -277,16 +221,3 @@ SCHEMA_RUN_TABLE_SIZE_CALCULATIONS = parse_boolean(os.environ.get("REDASH_SCHEMA
|
||||
# Allow Parameters in Embeds
|
||||
# WARNING: With this option enabled, Redash reads query parameters from the request URL (risk of SQL injection!)
|
||||
ALLOW_PARAMETERS_IN_EMBEDS = parse_boolean(os.environ.get("REDASH_ALLOW_PARAMETERS_IN_EMBEDS", "false"))
|
||||
|
||||
# Common Client config
|
||||
COMMON_CLIENT_CONFIG = {
|
||||
'allowScriptsInUserInput': ALLOW_SCRIPTS_IN_USER_INPUT,
|
||||
'showPermissionsControl': FEATURE_SHOW_PERMISSIONS_CONTROL,
|
||||
'allowCustomJSVisualizations': FEATURE_ALLOW_CUSTOM_JS_VISUALIZATIONS,
|
||||
'autoPublishNamedQueries': FEATURE_AUTO_PUBLISH_NAMED_QUERIES,
|
||||
'dateFormat': DATE_FORMAT,
|
||||
'dateTimeFormat': "{0} HH:mm".format(DATE_FORMAT),
|
||||
'allowAllToEditQueries': FEATURE_ALLOW_ALL_TO_EDIT_QUERIES,
|
||||
'mailSettingsMissing': MAIL_DEFAULT_SENDER is None,
|
||||
'logoUrl': LOGO_URL
|
||||
}
|
47
redash/settings/helpers.py
Normal file
47
redash/settings/helpers.py
Normal file
@ -0,0 +1,47 @@
|
||||
import json
|
||||
import os
|
||||
import urlparse
|
||||
|
||||
|
||||
def parse_db_url(url):
|
||||
url_parts = urlparse.urlparse(url)
|
||||
connection = {'threadlocals': True}
|
||||
|
||||
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(s):
|
||||
array = s.split(',')
|
||||
if "" in array:
|
||||
array.remove("")
|
||||
|
||||
return array
|
||||
|
||||
|
||||
def set_from_string(s):
|
||||
return set(array_from_string(s))
|
||||
|
||||
|
||||
def parse_boolean(str):
|
||||
return json.loads(str.lower())
|
||||
|
||||
|
||||
def int_or_none(value):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
return int(value)
|
27
redash/settings/organization.py
Normal file
27
redash/settings/organization.py
Normal file
@ -0,0 +1,27 @@
|
||||
import os
|
||||
from .helpers import parse_boolean
|
||||
|
||||
if os.environ.get("REDASH_SAML_LOCAL_METADATA_PATH") is not None:
|
||||
print "DEPRECATION NOTICE:\n"
|
||||
print "SAML_LOCAL_METADATA_PATH is no longer supported. Only URL metadata is supported now, please update"
|
||||
print "your configuration and reload."
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
PASSWORD_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_PASSWORD_LOGIN_ENABLED", "true"))
|
||||
|
||||
SAML_METADATA_URL = os.environ.get("REDASH_SAML_METADATA_URL", "")
|
||||
SAML_ENTITY_ID = os.environ.get("REDASH_SAML_ENTITY_ID", "")
|
||||
SAML_NAMEID_FORMAT = os.environ.get("REDASH_SAML_NAMEID_FORMAT", "")
|
||||
SAML_LOGIN_ENABLED = SAML_METADATA_URL != ""
|
||||
|
||||
DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
|
||||
|
||||
settings = {
|
||||
"auth_password_login_enabled": PASSWORD_LOGIN_ENABLED,
|
||||
"auth_saml_enabled": SAML_LOGIN_ENABLED,
|
||||
"auth_saml_entity_id": SAML_ENTITY_ID,
|
||||
"auth_saml_metadata_url": SAML_METADATA_URL,
|
||||
"auth_saml_nameid_format": SAML_NAMEID_FORMAT,
|
||||
"date_format": DATE_FORMAT
|
||||
}
|
@ -33,7 +33,7 @@ gunicorn==19.7.1
|
||||
celery==3.1.23
|
||||
jsonschema==2.4.0
|
||||
RestrictedPython==3.6.0
|
||||
pysaml2==2.4.0
|
||||
pysaml2==4.5.0
|
||||
pycrypto==2.6.1
|
||||
funcy==1.7.1
|
||||
raven==6.0.0
|
||||
|
@ -1,5 +1,6 @@
|
||||
pytest==3.2.3
|
||||
pytest-cov==2.5.1
|
||||
pytest-watch==4.1.0
|
||||
coverage==4.0.3
|
||||
mock==2.0.0
|
||||
|
||||
|
15
tests/handlers/test_settings.py
Normal file
15
tests/handlers/test_settings.py
Normal file
@ -0,0 +1,15 @@
|
||||
from tests import BaseTestCase
|
||||
from redash.models import Organization
|
||||
|
||||
|
||||
class TestOrganizationSettings(BaseTestCase):
|
||||
def test_post(self):
|
||||
admin = self.factory.create_admin()
|
||||
rv = self.make_request('post', '/api/settings/organization', data={'auth_password_login_enabled': False}, user=admin)
|
||||
self.assertEqual(rv.json['settings']['auth_password_login_enabled'], False)
|
||||
self.assertEqual(self.factory.org.settings['settings']['auth_password_login_enabled'], False)
|
||||
|
||||
rv = self.make_request('post', '/api/settings/organization', data={'auth_password_login_enabled': True}, user=admin)
|
||||
updated_org = Organization.get_by_slug(self.factory.org.slug)
|
||||
self.assertEqual(rv.json['settings']['auth_password_login_enabled'], True)
|
||||
self.assertEqual(updated_org.settings['settings']['auth_password_login_enabled'], True)
|
@ -57,6 +57,14 @@ class TestCreateTable(TestCase):
|
||||
create_table(connection, table_name, results)
|
||||
connection.execute('SELECT 1 FROM query_123')
|
||||
|
||||
def test_creates_table_with_non_ascii_in_column_name(self):
|
||||
connection = sqlite3.connect(':memory:')
|
||||
results = {'columns': [{'name': u'\xe4'}, {'name': 'test2'}], 'rows': [
|
||||
{u'\xe4': 1, 'test2': 2}]}
|
||||
table_name = 'query_123'
|
||||
create_table(connection, table_name, results)
|
||||
connection.execute('SELECT 1 FROM query_123')
|
||||
|
||||
def test_loads_results(self):
|
||||
connection = sqlite3.connect(':memory:')
|
||||
rows = [{'test1': 1, 'test2': 'test'}, {'test1': 2, 'test2': 'test2'}]
|
||||
|
@ -72,8 +72,8 @@ class JobAPITest(BaseTestCase, AuthenticationTestMixin):
|
||||
|
||||
class TestLogin(BaseTestCase):
|
||||
def setUp(self):
|
||||
settings.PASSWORD_LOGIN_ENABLED = True
|
||||
super(TestLogin, self).setUp()
|
||||
self.factory.org.set_setting('auth_password_login_enabled', True)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@ -84,10 +84,12 @@ class TestLogin(BaseTestCase):
|
||||
settings.ORG_RESOLVING = "multi_org"
|
||||
|
||||
def test_redirects_to_google_login_if_password_disabled(self):
|
||||
with patch.object(settings, 'PASSWORD_LOGIN_ENABLED', False), self.app.test_request_context('/default/login'):
|
||||
self.factory.org.set_setting('auth_password_login_enabled', False)
|
||||
with self.app.test_request_context('/default/login'):
|
||||
rv = self.client.get('/default/login')
|
||||
self.assertEquals(rv.status_code, 302)
|
||||
self.assertTrue(rv.location.endswith(url_for('google_oauth.authorize', next='/default/')))
|
||||
self.factory.org.set_setting('auth_password_login_enabled', True)
|
||||
|
||||
def test_get_login_form(self):
|
||||
rv = self.client.get('/default/login')
|
||||
|
Loading…
Reference in New Issue
Block a user