Merge branch 'master' of https://github.com/getredash/redash into design/new-data-source+query-editor

This commit is contained in:
Zsolt Kocsmarszky 2018-01-03 12:32:52 +01:00
commit a26dbab28b
50 changed files with 609 additions and 247 deletions

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.cache
.coverage.*
.coveralls.yml
.idea
*.pyc

View File

@ -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)

View File

@ -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
View 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" *

View File

@ -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, '')

View File

@ -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/\+/./")

View File

@ -11,6 +11,7 @@ counter-renderer counter {
display: block;
font-size: 80px;
overflow: hidden;
height: 200px;
}
counter-renderer value,

View 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);
}
}
}

View 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;
});
},
});
}

View 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>

View File

@ -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;
}
});
},
});
}

View File

@ -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>

View File

@ -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);
},
}));
});
}

View File

@ -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);
});

View File

@ -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;
}

View File

@ -13,7 +13,7 @@
<body>
<section>
<div ng-view></div>
<app-view></app-view>
</section>
</body>
</html>

View 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;

View File

@ -13,7 +13,7 @@
<body>
<section>
<div ng-view></div>
<app-view></app-view>
</section>
</body>
</html>

View File

@ -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 = {};

View File

@ -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);

View File

@ -95,6 +95,7 @@
top: 15px;
right: 10px;
bottom: 15px;
height: auto;
overflow: hidden;
padding: 0;
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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,

View 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>

View 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',
},
};
}

View File

@ -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,

View File

@ -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/>

View File

@ -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>

View File

@ -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();
});
}
});
});
}

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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')

View File

@ -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()
})

View 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
}

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View 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)

View 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
}

View File

@ -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

View File

@ -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

View 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)

View File

@ -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'}]

View File

@ -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')