Merge pull request #1098 from getredash/flexible_notifications

Feature: UI for alert destinations & new destination types
This commit is contained in:
Arik Fraimovich
2016-06-07 15:18:11 +03:00
39 changed files with 941 additions and 303 deletions

View File

@@ -0,0 +1,67 @@
from redash import settings
from redash.models import db, NotificationDestination, AlertSubscription, Alert
from redash.destinations import get_configuration_schema_for_destination_type
from redash.utils.configuration import ConfigurationContainer
from playhouse.migrate import PostgresqlMigrator, migrate
if __name__ == '__main__':
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
if not NotificationDestination.table_exists():
NotificationDestination.create_table()
# Update alert subscription fields
migrate(
migrator.add_column('alert_subscriptions', 'destination_id', AlertSubscription.destination)
)
if settings.WEBHOOK_ENDPOINT:
# Have all existing alerts send to webhook if already configured
schema = get_configuration_schema_for_destination_type('webhook')
conf = {'url': settings.WEBHOOK_ENDPOINT}
if settings.WEBHOOK_USERNAME:
conf['username'] = settings.WEBHOOK_USERNAME
conf['password'] = settings.WEBHOOK_PASSWORD
options = ConfigurationContainer(conf, schema)
webhook = NotificationDestination.create(
org=1,
user=1,
name="Webhook",
type="webhook",
options=options
)
for alert in Alert.select():
AlertSubscription.create(
user=1,
destination=webhook,
alert=alert
)
if settings.HIPCHAT_API_TOKEN:
# Have all existing alerts send to HipChat if already configured
schema = get_configuration_schema_for_destination_type('hipchat')
conf = {'token': settings.HIPCHAT_API_TOKEN,
'room_id': settings.HIPCHAT_ROOM_ID}
if settings.HIPCHAT_API_URL:
conf['url'] = settings.HIPCHAT_API_URL
options = ConfigurationContainer(conf, schema)
hipchat = NotificationDestination.create(
org=1,
user=1,
name="HipChat",
type="hipchat",
options=options
)
for alert in Alert.select():
AlertSubscription.create(
user=1,
destination=hipchat,
alert=alert
)
db.close_db(None)

View File

@@ -74,6 +74,7 @@
<script src="/scripts/controllers/dashboard.js"></script> <script src="/scripts/controllers/dashboard.js"></script>
<script src="/scripts/controllers/admin_controllers.js"></script> <script src="/scripts/controllers/admin_controllers.js"></script>
<script src="/scripts/controllers/data_sources.js"></script> <script src="/scripts/controllers/data_sources.js"></script>
<script src="/scripts/controllers/destinations.js"></script>
<script src="/scripts/controllers/query_view.js"></script> <script src="/scripts/controllers/query_view.js"></script>
<script src="/scripts/controllers/query_source.js"></script> <script src="/scripts/controllers/query_source.js"></script>
<script src="/scripts/controllers/users.js"></script> <script src="/scripts/controllers/users.js"></script>
@@ -89,7 +90,6 @@
<script src="/scripts/visualizations/date_range_selector.js"></script> <script src="/scripts/visualizations/date_range_selector.js"></script>
<script src="/scripts/directives/directives.js"></script> <script src="/scripts/directives/directives.js"></script>
<script src="/scripts/directives/query_directives.js"></script> <script src="/scripts/directives/query_directives.js"></script>
<script src="/scripts/directives/data_source_directives.js"></script>
<script src="/scripts/directives/dashboard_directives.js"></script> <script src="/scripts/directives/dashboard_directives.js"></script>
<script src="/scripts/filters.js"></script> <script src="/scripts/filters.js"></script>
<script src="/scripts/controllers/alerts.js"></script> <script src="/scripts/controllers/alerts.js"></script>

View File

@@ -1,7 +1,5 @@
{% extends 'app_layout.html' %} {% extends 'app_layout.html' %}
{% block content %} {% block content %}
<app-header></app-header> <app-header></app-header>
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form> <edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
{% endblock %} {% endblock %}

View File

@@ -115,6 +115,15 @@ angular.module('redash', [
controller: 'DataSourcesCtrl' controller: 'DataSourcesCtrl'
}); });
$routeProvider.when('/destinations/:destinationId', {
templateUrl: '/views/destinations/edit.html',
controller: 'DestinationCtrl'
});
$routeProvider.when('/destinations', {
templateUrl: '/views/destinations/list.html',
controller: 'DestinationsCtrl'
});
$routeProvider.when('/users/new', { $routeProvider.when('/users/new', {
templateUrl: '/views/users/new.html', templateUrl: '/views/users/new.html',
controller: 'NewUserCtrl' controller: 'NewUserCtrl'

View File

@@ -46,7 +46,8 @@
]; ];
}; };
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) { var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) {
$scope.selectedTab = 'users';
$scope.$parent.pageTitle = "Alerts"; $scope.$parent.pageTitle = "Alerts";
$scope.alertId = $routeParams.alertId; $scope.alertId = $routeParams.alertId;
@@ -108,69 +109,109 @@
growl.addErrorMessage("Failed saving alert."); growl.addErrorMessage("Failed saving alert.");
}); });
}; };
}; };
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) { angular.module('redash.directives').directive('alertSubscriptions', ['$q', '$sce', 'AlertSubscription', 'Destination', 'growl', function ($q, $sce, AlertSubscription, Destination, growl) {
return { return {
restrict: 'E', restrict: 'E',
replace: true, replace: true,
templateUrl: '/views/alerts/subscribers.html', templateUrl: '/views/alerts/alert_subscriptions.html',
scope: { scope: {
'alertId': '=' 'alertId': '='
}, },
controller: function ($scope) { controller: function ($scope) {
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId}); $scope.newSubscription = {};
} $scope.subscribers = [];
} $scope.destinations = [];
}]); $scope.currentUser = currentUser;
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) { var destinations = Destination.query().$promise;
return { var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
restrict: 'E',
replace: true, $q.all([destinations, subscribers]).then(function(responses) {
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>', var destinations = responses[0];
controller: function ($scope) { var subscribers = responses[1];
var updateClass = function() {
if ($scope.subscription) { var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
$scope.class = "fa fa-eye-slash"; var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
} else {
$scope.class = "fa fa-eye"; $scope.destinations = _.filter(destinations, function(d) { return !_.contains(subscribedDestinations, d.id); });
if (!_.contains(subscribedUsers, currentUser.id)) {
$scope.destinations.unshift({user: {name: currentUser.name}});
} }
}
$scope.subscribers.$promise.then(function() { $scope.newSubscription.destination = $scope.destinations[0];
$scope.subscription = _.find($scope.subscribers, function(subscription) { $scope.subscribers = subscribers;
return (subscription.user.email == currentUser.email);
});
updateClass();
}); });
$scope.toggleSubscription = function() { $scope.destinationsDisplay = function(destination) {
if ($scope.subscription) { if (!destination) {
$scope.subscription.$delete(function() { return '';
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
$scope.subscription = undefined;
updateClass();
}, function() {
growl.addErrorMessage("Failed saving subscription.");
});
} else {
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
$scope.subscription.$save(function() {
$scope.subscribers.push($scope.subscription);
updateClass();
}, function() {
growl.addErrorMessage("Unsubscription failed.");
});
} }
}
if (destination.destination) {
destination = destination.destination;
} else if (destination.user) {
destination = {
name: destination.user.name + ' (Email)',
icon: 'fa-envelope',
type: 'user'
};
}
return $sce.trustAsHtml('<i class="fa ' + destination.icon + '"></i>&nbsp;' + destination.name);
};
$scope.saveSubscriber = function() {
var sub = new AlertSubscription({alert_id: $scope.alertId});
if ($scope.newSubscription.destination.id) {
sub.destination_id = $scope.newSubscription.destination.id;
}
sub.$save(function () {
growl.addSuccessMessage("Subscribed.");
$scope.subscribers.push(sub);
$scope.destinations = _.without($scope.destinations, $scope.newSubscription.destination);
if ($scope.destinations.length > 0) {
$scope.newSubscription.destination = $scope.destinations[0];
} else {
$scope.newSubscription.destination = undefined;
}
console.log("dests: ", $scope.destinations);
}, function (response) {
growl.addErrorMessage("Failed saving subscription.");
});
};
$scope.unsubscribe = function(subscriber) {
var destination = subscriber.destination;
var user = subscriber.user;
subscriber.$delete(function () {
growl.addSuccessMessage("Unsubscribed");
$scope.subscribers = _.without($scope.subscribers, subscriber);
if (destination) {
$scope.destinations.push(destination);
} else if (user.id == currentUser.id) {
$scope.destinations.push({user: {name: currentUser.name}});
}
if ($scope.destinations.length == 1) {
$scope.newSubscription.destination = $scope.destinations[0];
}
}, function () {
growl.addErrorMessage("Failed unsubscribing.");
});
};
} }
} }
}]); }]);
angular.module('redash.controllers') angular.module('redash.controllers')
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl]) .controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl]) .controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', 'Destination', AlertCtrl])
})(); })();

View File

@@ -7,7 +7,7 @@
}; };
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) { var DataSourceCtrl = function ($scope, $routeParams, $http, $location, growl, Events, DataSource) {
Events.record(currentUser, "view", "page", "admin/data_source"); Events.record(currentUser, "view", "page", "admin/data_source");
$scope.$parent.pageTitle = "Data Sources"; $scope.$parent.pageTitle = "Data Sources";
@@ -24,9 +24,21 @@
$location.path('/data_sources/' + id).replace(); $location.path('/data_sources/' + id).replace();
} }
}); });
$scope.delete = function () {
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
$scope.dataSource.$delete(function (resource) {
growl.addSuccessMessage("Data source deleted successfully.");
$location.path('/data_sources/');
}.bind(this), function (httpResponse) {
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete data source.");
});
}
}; };
angular.module('redash.controllers') angular.module('redash.controllers')
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl]) .controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl]) .controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'DataSource', DataSourceCtrl])
})(); })();

View File

@@ -0,0 +1,44 @@
(function () {
var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) {
Events.record(currentUser, "view", "page", "admin/destinations");
$scope.$parent.pageTitle = "Destinations";
$scope.destinations = Destination.query();
};
var DestinationCtrl = function ($scope, $routeParams, $http, $location, growl, Events, Destination) {
Events.record(currentUser, "view", "page", "admin/destination");
$scope.$parent.pageTitle = "Destinations";
$scope.destinationId = $routeParams.destinationId;
if ($scope.destinationId == "new") {
$scope.destination = new Destination({options: {}});
} else {
$scope.destination = Destination.get({id: $routeParams.destinationId});
}
$scope.$watch('destination.id', function(id) {
if (id != $scope.destinationId && id !== undefined) {
$location.path('/destinations/' + id).replace();
}
});
$scope.delete = function() {
Events.record(currentUser, "delete", "destination", $scope.destination.id);
$scope.destination.$delete(function(resource) {
growl.addSuccessMessage("Destination deleted successfully.");
$location.path('/destinations/');
}.bind(this), function(httpResponse) {
console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete destination.");
});
}
};
angular.module('redash.controllers')
.controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl])
.controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Destination', DestinationCtrl])
})();

View File

@@ -1,95 +0,0 @@
(function () {
'use strict';
var directives = angular.module('redash.directives');
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
directives.directive('sourceForm', ['$http', 'growl', '$q', '$location', 'Events', function ($http, growl, $q, $location, Events) {
return {
restrict: 'E',
replace: true,
templateUrl: '/views/data_sources/form.html',
scope: {
'dataSource': '='
},
link: function ($scope) {
var setType = function(types) {
if ($scope.dataSource.type === undefined) {
$scope.dataSource.type = types[0].type;
return types[0];
}
$scope.type = _.find(types, function (t) {
return t.type == $scope.dataSource.type;
});
};
$scope.files = {};
$scope.$watchCollection('files', function() {
_.each($scope.files, function(v, k) {
if (v) {
$scope.dataSource.options[k] = v.base64;
}
});
});
var typesPromise = $http.get('api/data_sources/types');
$q.all([typesPromise, $scope.dataSource.$promise]).then(function(responses) {
var types = responses[0].data;
setType(types);
$scope.dataSourceTypes = types;
_.each(types, function (type) {
_.each(type.configuration_schema.properties, function (prop, name) {
if (name == 'password' || name == 'passwd') {
prop.type = 'password';
}
if (_.string.endsWith(name, "File")) {
prop.type = 'file';
}
if (prop.type == 'boolean') {
prop.type = 'checkbox';
}
prop.required = _.contains(type.configuration_schema.required, name);
});
});
});
$scope.$watch('dataSource.type', function(current, prev) {
if (prev !== current) {
if (prev !== undefined) {
$scope.dataSource.options = {};
}
setType($scope.dataSourceTypes);
}
});
$scope.saveChanges = function() {
$scope.dataSource.$save(function() {
growl.addSuccessMessage("Saved.");
}, function() {
growl.addErrorMessage("Failed saving.");
});
}
$scope.deleteDataSource = function() {
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
$scope.dataSource.$delete(function(resource) {
growl.addSuccessMessage("Data source deleted successfully.");
$location.path('/data_sources/');
}.bind(this), function(httpResponse) {
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
growl.addErrorMessage("Failed to delete data source.");
});
}
}
}
}]);
})();

View File

@@ -383,7 +383,86 @@
'</div>' + '</div>' +
'</div>' '</div>'
} }
}) });
directives.directive('dynamicForm', ['$http', 'growl', '$q', function ($http, growl, $q) {
return {
restrict: 'E',
replace: 'true',
transclude: true,
templateUrl: '/views/directives/dynamic_form.html',
scope: {
'target': '=',
'type': '@type'
},
link: function ($scope) {
var setType = function(types) {
if ($scope.target.type === undefined) {
$scope.target.type = types[0].type;
return types[0];
}
$scope.type = _.find(types, function (t) {
return t.type == $scope.target.type;
});
};
$scope.files = {};
$scope.$watchCollection('files', function() {
_.each($scope.files, function(v, k) {
if (v) {
$scope.target.options[k] = v.base64;
}
});
});
var typesPromise = $http.get('api/' + $scope.type + '/types');
$q.all([typesPromise, $scope.target.$promise]).then(function(responses) {
var types = responses[0].data;
setType(types);
$scope.types = types;
_.each(types, function (type) {
_.each(type.configuration_schema.properties, function (prop, name) {
if (name == 'password' || name == 'passwd') {
prop.type = 'password';
}
if (_.string.endsWith(name, "File")) {
prop.type = 'file';
}
if (prop.type == 'boolean') {
prop.type = 'checkbox';
}
prop.required = _.contains(type.configuration_schema.required, name);
});
});
});
$scope.$watch('target.type', function(current, prev) {
if (prev !== current) {
if (prev !== undefined) {
$scope.target.options = {};
}
setType($scope.types);
}
});
$scope.saveChanges = function() {
$scope.target.$save(function() {
growl.addSuccessMessage("Saved.");
}, function() {
growl.addErrorMessage("Failed saving.");
});
}
}
}
}]);
directives.directive('pageHeader', function() { directives.directive('pageHeader', function() {
return { return {
@@ -407,10 +486,12 @@
scope.usersPage = _.string.startsWith($location.path(), '/users'); scope.usersPage = _.string.startsWith($location.path(), '/users');
scope.groupsPage = _.string.startsWith($location.path(), '/groups'); scope.groupsPage = _.string.startsWith($location.path(), '/groups');
scope.dsPage = _.string.startsWith($location.path(), '/data_sources'); scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
scope.showGroupsLink = currentUser.hasPermission('list_users'); scope.showGroupsLink = currentUser.hasPermission('list_users');
scope.showUsersLink = currentUser.hasPermission('list_users'); scope.showUsersLink = currentUser.hasPermission('list_users');
scope.showDsLink = currentUser.hasPermission('admin'); scope.showDsLink = currentUser.hasPermission('admin');
scope.showDestinationsLink = currentUser.hasPermission('admin');
} }
} }
}]); }]);

View File

@@ -567,6 +567,17 @@
return DataSourceResource; return DataSourceResource;
}; };
var Destination = function ($resource) {
var actions = {
'get': {'method': 'GET', 'cache': false, 'isArray': false},
'query': {'method': 'GET', 'cache': false, 'isArray': true}
};
var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions);
return DestinationResource;
};
var User = function ($resource, $http) { var User = function ($resource, $http) {
var transformSingle = function(user) { var transformSingle = function(user) {
if (user.groups !== undefined) { if (user.groups !== undefined) {
@@ -607,7 +618,7 @@
}; };
var AlertSubscription = function ($resource) { var AlertSubscription = function ($resource) {
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'}); var resource = $resource('api/alerts/:alertId/subscriptions/:subscriberId', {alertId: '@alert_id', subscriberId: '@id'});
return resource; return resource;
}; };
@@ -619,7 +630,9 @@
var newData = _.extend({}, data); var newData = _.extend({}, data);
if (newData.query_id === undefined) { if (newData.query_id === undefined) {
newData.query_id = newData.query.id; newData.query_id = newData.query.id;
newData.destination_id = newData.destinations;
delete newData.query; delete newData.query;
delete newData.destinations;
} }
return newData; return newData;
@@ -656,6 +669,7 @@
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult]) .factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query]) .factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
.factory('DataSource', ['$resource', DataSource]) .factory('DataSource', ['$resource', DataSource])
.factory('Destination', ['$resource', Destination])
.factory('Alert', ['$resource', '$http', Alert]) .factory('Alert', ['$resource', '$http', Alert])
.factory('AlertSubscription', ['$resource', AlertSubscription]) .factory('AlertSubscription', ['$resource', AlertSubscription])
.factory('Widget', ['$resource', 'Query', Widget]) .factory('Widget', ['$resource', 'Query', Widget])

View File

@@ -657,3 +657,8 @@ div.table-name:hover {
.t-body a.actions.open > a { .t-body a.actions.open > a {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
hr.subscription {
height: 2px;
background: #333;
}

View File

@@ -0,0 +1,27 @@
<div class="p-5">
<h4>Notifications</h4>
<div>
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
<ui-select-choices repeat="d in destinations">
<span ng-bind-html="destinationsDisplay(d)"></span>
</ui-select-choices>
</ui-select>
</div>
<div class="m-t-5">
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
<span class="pull-right m-t-5">
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
</span>
</div>
<hr/>
<div>
<div class="list-group-item" ng-repeat="subscriber in subscribers">
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
</div>
</div>
</div>

View File

@@ -62,7 +62,7 @@
</form> </form>
</div> </div>
<div class="col-md-4" ng-if="alert.id"> <div class="col-md-4" ng-if="alert.id">
<alert-subscribers alert-id="alert.id"></alert-subscribers> <alert-subscriptions alert-id="alertId"></alert-subscriptions>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +0,0 @@
<div>
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
</div>

View File

@@ -1,7 +1,9 @@
<settings-screen> <settings-screen>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<data-source-form data-data-source="dataSource" /> <dynamic-form target="dataSource" type="data_sources">
<button class="btn btn-danger" ng-if="dataSource.id" ng-click="delete()">Delete</button>
</dynamic-form>
</div> </div>
</div> </div>
</settings-screen> </settings-screen>

View File

@@ -1,28 +0,0 @@
<div>
<form name="dataSourceForm" ng-submit="saveChanges()">
<div class="form-group">
<label for="dataSourceName">Name</label>
<input type="string" class="form-control" name="dataSourceName" ng-model="dataSource.name" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" class="form-control" ng-options="type.type as type.name for type in dataSourceTypes"
ng-model="dataSource.type"></select>
</div>
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner"
ng-repeat="(name, input) in type.configuration_schema.properties">
<label>{{input.title || name | capitalize}}</label>
<input name="input" type="{{input.type}}" class="form-control" ng-model="dataSource.options[name]"
ng-required="input.required"
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
<input name="input" type="file" class="form-control" ng-model="files[name]"
ng-required="input.required && !dataSource.options[name]"
base-sixty-four-input
ng-if="input.type === 'file'">
</div>
</form>
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
<button class="btn btn-danger" ng-click="deleteDataSource()" ng-if="dataSource.id">Delete</button>
</div>

View File

@@ -0,0 +1,11 @@
<settings-screen>
<div class="row voffset1">
<div class="col-md-6">
<dynamic-form target="destination" type="destinations">
<button class="btn btn-danger" ng-if="destination.id" ng-click="delete()">Delete</button>
</dynamic-form>
</div>
</div>
</settings-screen>

View File

@@ -0,0 +1,12 @@
<settings-screen>
<div class="row voffset1">
<div class="col-md-4">
<p>
<a href="destinations/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert Destination</a>
</p>
<div class="list-group">
<a ng-href="destinations/{{destination.id}}" class="list-group-item" ng-repeat="destination in destinations"><i class="fa {{destination.icon}}"></i> {{destination.name}}</a>
</div>
</div>
</div>
</settings-screen>

View File

@@ -0,0 +1,23 @@
<form name="dataSourceForm">
<div class="form-group">
<label for="dataSourceName">Name</label>
<input type="string" class="form-control" name="dataSourceName" ng-model="target.name" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<select name="type" class="form-control" ng-options="type.type as type.name for type in types" ng-model="target.type"></select>
</div>
<div class="form-group" ng-class='{"has-error": !inner.input.$valid}' ng-form="inner" ng-repeat="(name, input) in type.configuration_schema.properties">
<label>{{input.title || name | capitalize}}</label>
<input name="input" type="{{input.type}}" class="form-control" ng-model="target.options[name]" ng-required="input.required"
ng-if="input.type !== 'file'" accesskey="tab" placeholder="{{input.default}}">
<input name="input" type="file" class="form-control" ng-model="files[name]" ng-required="input.required && !target.options[name]"
base-sixty-four-input
ng-if="input.type === 'file'">
</div>
<button class="btn btn-primary" ng-disabled="!dataSourceForm.$valid" ng-click="saveChanges()">Save</button>
<span ng-transclude>
</span>
</form>

View File

@@ -7,6 +7,7 @@
<li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li> <li ng-class="{'active': dsPage }" ng-if="showDsLink"><a href="data_sources">Data Sources</a></li>
<li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li> <li ng-class="{'active': usersPage }" ng-if="showUsersLink"><a href="users">Users</a></li>
<li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li> <li ng-class="{'active': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>
<li ng-class="{'active': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
</ul> </ul>
<div ng-transclude> <div ng-transclude>

View File

@@ -10,6 +10,7 @@ from flask_mail import Mail
from redash import settings from redash import settings
from redash.query_runner import import_query_runners from redash.query_runner import import_query_runners
from redash.destinations import import_destinations
__version__ = '0.11.0' __version__ = '0.11.0'
@@ -61,6 +62,7 @@ mail.init_mail(settings.all_settings())
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX) statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
import_query_runners(settings.QUERY_RUNNERS) import_query_runners(settings.QUERY_RUNNERS)
import_destinations(settings.DESTINATIONS)
from redash.version_check import reset_new_version_status from redash.version_check import reset_new_version_status
reset_new_version_status() reset_new_version_status()

View File

@@ -2,7 +2,7 @@ import json
import click import click
from flask_script import Manager from flask_script import Manager
from redash import models from redash import models
from redash.query_runner import query_runners, get_configuration_schema_for_type from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
from redash.utils.configuration import ConfigurationContainer from redash.utils.configuration import ConfigurationContainer
manager = Manager(help="Data sources management commands.") manager = Manager(help="Data sources management commands.")
@@ -119,7 +119,7 @@ def edit(name, new_name=None, options=None, type=None):
data_source = models.DataSource.get(models.DataSource.name==name) data_source = models.DataSource.get(models.DataSource.name==name)
if options is not None: if options is not None:
schema = get_configuration_schema_for_type(data_source.type) schema = get_configuration_schema_for_query_runner_type(data_source.type)
options = json.loads(options) options = json.loads(options)
data_source.options.set_schema(schema) data_source.options.set_schema(schema)
data_source.options.update(options) data_source.options.update(options)

View File

@@ -0,0 +1,82 @@
import logging
import json
from redash import settings
logger = logging.getLogger(__name__)
__all__ = [
'BaseDestination',
'register',
'get_destination',
'import_destinations'
]
class BaseDestination(object):
def __init__(self, configuration):
self.configuration = configuration
@classmethod
def name(cls):
return cls.__name__
@classmethod
def type(cls):
return cls.__name__.lower()
@classmethod
def icon(cls):
return 'fa-bullseye'
@classmethod
def enabled(cls):
return True
@classmethod
def configuration_schema(cls):
return {}
def notify(self, alert, query, user, new_state, app, host, options):
raise NotImplementedError()
@classmethod
def to_dict(cls):
return {
'name': cls.name(),
'type': cls.type(),
'icon': cls.icon(),
'configuration_schema': cls.configuration_schema()
}
destinations = {}
def register(destination_class):
global destinations
if destination_class.enabled():
logger.debug("Registering %s (%s) destinations.", destination_class.name(), destination_class.type())
destinations[destination_class.type()] = destination_class
else:
logger.warning("%s destination enabled but not supported, not registering. Either disable or install missing dependencies.", destination_class.name())
def get_destination(destination_type, configuration):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class(configuration)
def get_configuration_schema_for_destination_type(destination_type):
destination_class = destinations.get(destination_type, None)
if destination_class is None:
return None
return destination_class.configuration_schema()
def import_destinations(destination_imports):
for destination_import in destination_imports:
__import__(destination_import)

View File

@@ -0,0 +1,44 @@
import logging
from flask_mail import Message
from redash import models, mail
from redash.destinations import *
class Email(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"addresses": {
"type": "string"
},
},
"required": ["addresses"]
}
@classmethod
def icon(cls):
return 'fa-envelope'
def notify(self, alert, query, user, new_state, app, host, options):
recipients = [email for email in options.get('addresses').split(',') if email]
html = """
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
""".format(host=host, alert_id=alert.id, query_id=query.id)
logging.debug("Notifying: %s", recipients)
try:
with app.app_context():
message = Message(
recipients=recipients,
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
html=html
)
mail.send(message)
except Exception:
logging.exception("mail send ERROR.")
register(Email)

View File

@@ -0,0 +1,47 @@
import logging
import hipchat
from redash import settings
from redash.destinations import *
class HipChat(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {
"type": "string"
},
"token": {
"type": "string"
},
"room_id": {
"type": "string"
}
},
"required": ["token", "room_id"],
"secret": ["token"]
}
@classmethod
def icon(cls):
return 'fa-comment-o'
def notify(self, alert, query, user, new_state, app, host, options):
try:
if options.url:
hipchat_client = hipchat.HipChat(token=options.token, url=options.url)
else:
hipchat_client = hipchat.HipChat(token=options.token)
html = """
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
""".format(host=host, alert_id=alert.id, query_id=query.id)
message = '[' + new_state.upper() + '] ' + alert.name + '<br />' + html
hipchat_client.message_room(options.room_id, settings.NAME, message.encode('utf-8', 'ignore'), message_format='html')
except Exception:
logging.exception("hipchat send ERROR.")
register(HipChat)

View File

@@ -0,0 +1,38 @@
import json
import logging
import requests
from redash.destinations import *
class Slack(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
'type': 'object',
'properties': {
'url': {
'type': 'string',
'title': 'Slack Webhook URL'
}
}
}
@classmethod
def icon(cls):
return 'fa-slack'
def notify(self, alert, query, user, new_state, app, host, options):
msg = "Check <{host}/alerts/{alert_id}|alert> / check <{host}/queries/{query_id}|query>".format(
host=host, alert_id=alert.id, query_id=query.id)
payload = {'text': msg}
try:
resp = requests.post(options.get('url'), data=json.dumps(payload))
logging.warning(resp.text)
if resp.status_code != 200:
logging.error("Slack send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("Slack send ERROR.")
register(Slack)

View File

@@ -0,0 +1,49 @@
import logging
import requests
from requests.auth import HTTPBasicAuth
from redash.destinations import *
from redash.utils import json_dumps
class Webhook(BaseDestination):
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
"url": {
"type": "string",
},
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"required": ["url"],
"secret": ["password"]
}
@classmethod
def icon(cls):
return 'fa-bolt'
def notify(self, alert, query, user, new_state, app, host, options):
try:
data = {
'event': 'alert_state_change',
'alert': alert.to_dict(full=False),
'url_base': host
}
headers = {'Content-Type': 'application/json'}
auth = HTTPBasicAuth(options.get('username'), options.get('password')) if options.get('username') else None
resp = requests.post(options.get('url'), data=json_dumps(data), auth=auth, headers=headers)
if resp.status_code != 200:
logging.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logging.exception("webhook send ERROR.")
register(Webhook)

View File

@@ -57,16 +57,6 @@ class AlertListResource(BaseResource):
'object_type': 'alert' 'object_type': 'alert'
}) })
# TODO: should be in model?
models.AlertSubscription.create(alert=alert, user=self.current_user)
self.record_event({
'action': 'subscribe',
'timestamp': int(time.time()),
'object_id': alert.id,
'object_type': 'alert'
})
return alert.to_dict() return alert.to_dict()
@require_permission('list_alerts') @require_permission('list_alerts')
@@ -76,15 +66,24 @@ class AlertListResource(BaseResource):
class AlertSubscriptionListResource(BaseResource): class AlertSubscriptionListResource(BaseResource):
def post(self, alert_id): def post(self, alert_id):
req = request.get_json(True)
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org) alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
require_access(alert.groups, self.current_user, view_only) require_access(alert.groups, self.current_user, view_only)
kwargs = {'alert': alert, 'user': self.current_user}
if 'destination_id' in req:
destination = models.NotificationDestination.get_by_id_and_org(req['destination_id'], self.current_org)
kwargs['destination'] = destination
subscription = models.AlertSubscription.create(**kwargs)
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
self.record_event({ self.record_event({
'action': 'subscribe', 'action': 'subscribe',
'timestamp': int(time.time()), 'timestamp': int(time.time()),
'object_id': alert_id, 'object_id': alert_id,
'object_type': 'alert' 'object_type': 'alert',
'destination': req.get('destination_id')
}) })
return subscription.to_dict() return subscription.to_dict()
@@ -99,8 +98,10 @@ class AlertSubscriptionListResource(BaseResource):
class AlertSubscriptionResource(BaseResource): class AlertSubscriptionResource(BaseResource):
def delete(self, alert_id, subscriber_id): def delete(self, alert_id, subscriber_id):
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
require_admin_or_owner(subscriber_id) subscription = get_object_or_404(models.AlertSubscription.get_by_id, subscriber_id)
require_admin_or_owner(subscription.user.id)
subscription.delete_instance()
self.record_event({ self.record_event({
'action': 'unsubscribe', 'action': 'unsubscribe',

View File

@@ -16,6 +16,7 @@ from redash.handlers.visualizations import VisualizationResource
from redash.handlers.widgets import WidgetResource, WidgetListResource from redash.handlers.widgets import WidgetResource, WidgetListResource
from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \ from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \
GroupDataSourceListResource, GroupDataSourceResource GroupDataSourceListResource, GroupDataSourceResource
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
class ApiExt(Api): class ApiExt(Api):
@@ -86,4 +87,6 @@ api.add_org_resource(VisualizationResource, '/api/visualizations/<visualization_
api.add_org_resource(WidgetListResource, '/api/widgets', endpoint='widgets') api.add_org_resource(WidgetListResource, '/api/widgets', endpoint='widgets')
api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='widget') api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='widget')
api.add_org_resource(DestinationTypeListResource, '/api/destinations/types', endpoint='destination_types')
api.add_org_resource(DestinationResource, '/api/destinations/<destination_id>', endpoint='destination')
api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='destinations')

View File

@@ -5,7 +5,7 @@ from funcy import project
from redash import models from redash import models
from redash.utils.configuration import ConfigurationContainer, ValidationError from redash.utils.configuration import ConfigurationContainer, ValidationError
from redash.permissions import require_admin, require_permission, require_access, view_only from redash.permissions import require_admin, require_permission, require_access, view_only
from redash.query_runner import query_runners, get_configuration_schema_for_type from redash.query_runner import query_runners, get_configuration_schema_for_query_runner_type
from redash.handlers.base import BaseResource, get_object_or_404 from redash.handlers.base import BaseResource, get_object_or_404
@@ -26,7 +26,7 @@ class DataSourceResource(BaseResource):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org) data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
req = request.get_json(True) req = request.get_json(True)
schema = get_configuration_schema_for_type(req['type']) schema = get_configuration_schema_for_query_runner_type(req['type'])
if schema is None: if schema is None:
abort(400) abort(400)
@@ -35,7 +35,7 @@ class DataSourceResource(BaseResource):
data_source.options.update(req['options']) data_source.options.update(req['options'])
except ValidationError: except ValidationError:
abort(400) abort(400)
data_source.type = req['type'] data_source.type = req['type']
data_source.name = req['name'] data_source.name = req['name']
data_source.save() data_source.save()
@@ -77,7 +77,7 @@ class DataSourceListResource(BaseResource):
if f not in req: if f not in req:
abort(400) abort(400)
schema = get_configuration_schema_for_type(req['type']) schema = get_configuration_schema_for_query_runner_type(req['type'])
if schema is None: if schema is None:
abort(400) abort(400)

View File

@@ -0,0 +1,93 @@
import json
from flask import make_response, request
from flask.ext.restful import abort
from funcy import project
from redash import models
from redash.permissions import require_admin
from redash.destinations import destinations, get_configuration_schema_for_destination_type
from redash.utils.configuration import ConfigurationContainer, ValidationError
from redash.handlers.base import BaseResource, get_object_or_404
class DestinationTypeListResource(BaseResource):
@require_admin
def get(self):
return [q.to_dict() for q in destinations.values()]
class DestinationResource(BaseResource):
@require_admin
def get(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
return destination.to_dict(all=True)
@require_admin
def post(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
req = request.get_json(True)
schema = get_configuration_schema_for_destination_type(req['type'])
if schema is None:
abort(400)
try:
destination.options.set_schema(schema)
destination.options.update(req['options'])
except ValidationError:
abort(400)
destination.type = req['type']
destination.name = req['name']
destination.save()
return destination.to_dict(all=True)
@require_admin
def delete(self, destination_id):
destination = models.NotificationDestination.get_by_id_and_org(destination_id, self.current_org)
destination.delete_instance(recursive=True)
return make_response('', 204)
class DestinationListResource(BaseResource):
def get(self):
destinations = models.NotificationDestination.all(self.current_org)
response = {}
for ds in destinations:
if ds.id in response:
continue
d = ds.to_dict()
response[ds.id] = d
return response.values()
@require_admin
def post(self):
req = request.get_json(True)
required_fields = ('options', 'name', 'type')
for f in required_fields:
if f not in req:
abort(400)
schema = get_configuration_schema_for_destination_type(req['type'])
if schema is None:
abort(400)
config = ConfigurationContainer(req['options'], schema)
if not config.is_valid():
abort(400)
destination = models.NotificationDestination(org=self.current_org,
name=req['name'],
type=req['type'],
options=config,
user=self.current_user)
destination.save()
return destination.to_dict(all=True)

View File

@@ -176,5 +176,3 @@ class GroupDataSourceResource(BaseResource):
'object_type': 'group', 'object_type': 'group',
'member_id': data_source.id 'member_id': data_source.id
}) })

View File

@@ -22,7 +22,6 @@ def send_static(filename):
# The following is copied from send_from_directory, and extended to support multiple directories # The following is copied from send_from_directory, and extended to support multiple directories
for path in settings.STATIC_ASSETS_PATHS: for path in settings.STATIC_ASSETS_PATHS:
print path
full_path = safe_join(path, filename) full_path = safe_join(path, filename)
if os.path.isfile(full_path): if os.path.isfile(full_path):
return send_file(full_path, **dict(cache_timeout=cache_timeout, conditional=True)) return send_file(full_path, **dict(cache_timeout=cache_timeout, conditional=True))
@@ -80,6 +79,8 @@ rules = ['/admin/<anything>/<whatever>',
'/data_sources/<pk>', '/data_sources/<pk>',
'/users', '/users',
'/users/<pk>', '/users/<pk>',
'/destinations',
'/destinations/<pk>',
'/groups', '/groups',
'/groups/<pk>', '/groups/<pk>',
'/groups/<pk>/data_sources', '/groups/<pk>/data_sources',

View File

@@ -16,7 +16,8 @@ from playhouse.postgres_ext import ArrayField, DateTimeTZField
from permissions import has_access, view_only from permissions import has_access, view_only
from redash import utils, settings, redis_connection from redash import utils, settings, redis_connection
from redash.query_runner import get_query_runner, get_configuration_schema_for_type from redash.query_runner import get_query_runner, get_configuration_schema_for_query_runner_type
from redash.destinations import get_destination, get_configuration_schema_for_destination_type
from redash.metrics.database import MeteredPostgresqlExtDatabase, MeteredModel from redash.metrics.database import MeteredPostgresqlExtDatabase, MeteredModel
from redash.utils import generate_token from redash.utils import generate_token
from redash.utils.configuration import ConfigurationContainer from redash.utils.configuration import ConfigurationContainer
@@ -378,7 +379,7 @@ class DataSource(BelongsToOrgMixin, BaseModel):
} }
if all: if all:
schema = get_configuration_schema_for_type(self.type) schema = get_configuration_schema_for_query_runner_type(self.type)
self.options.set_schema(schema) self.options.set_schema(schema)
d['options'] = self.options.to_dict(mask_secrets=True) d['options'] = self.options.to_dict(mask_secrets=True)
d['queue_name'] = self.queue_name d['queue_name'] = self.queue_name
@@ -827,29 +828,6 @@ class Alert(ModelTimestampsMixin, BaseModel):
return self.query.groups return self.query.groups
class AlertSubscription(ModelTimestampsMixin, BaseModel):
user = peewee.ForeignKeyField(User)
alert = peewee.ForeignKeyField(Alert)
class Meta:
db_table = 'alert_subscriptions'
def to_dict(self):
return {
'user': self.user.to_dict(),
'alert_id': self.alert_id
}
@classmethod
def all(cls, alert_id):
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
@classmethod
def unsubscribe(cls, alert_id, user_id):
query = AlertSubscription.delete().where(AlertSubscription.alert==alert_id).where(AlertSubscription.user==user_id)
return query.execute()
class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin): class Dashboard(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
id = peewee.PrimaryKeyField() id = peewee.PrimaryKeyField()
org = peewee.ForeignKeyField(Organization, related_name="dashboards") org = peewee.ForeignKeyField(Organization, related_name="dashboards")
@@ -1120,7 +1098,101 @@ class ApiKey(ModelTimestampsMixin, BaseModel):
return cls.create(org=user.org, object=object, created_by=user) return cls.create(org=user.org, object=object, created_by=user)
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, AlertSubscription, Dashboard, Visualization, Widget, Event, ApiKey) class NotificationDestination(BelongsToOrgMixin, BaseModel):
id = peewee.PrimaryKeyField()
org = peewee.ForeignKeyField(Organization, related_name="notification_destinations")
user = peewee.ForeignKeyField(User, related_name="notification_destinations")
name = peewee.CharField()
type = peewee.CharField()
options = ConfigurationField()
created_at = DateTimeTZField(default=datetime.datetime.now)
class Meta:
db_table = 'notification_destinations'
indexes = (
(('org', 'name'), True),
)
def to_dict(self, all=False):
d = {
'id': self.id,
'name': self.name,
'type': self.type,
'icon': self.destination.icon()
}
if all:
schema = get_configuration_schema_for_destination_type(self.type)
self.options.set_schema(schema)
d['options'] = self.options.to_dict(mask_secrets=True)
return d
def __unicode__(self):
return self.name
@property
def destination(self):
return get_destination(self.type, self.options)
@classmethod
def all(cls, org):
notification_destinations = cls.select().where(cls.org==org).order_by(cls.id.asc())
return notification_destinations
def notify(self, alert, query, user, new_state, app, host):
schema = get_configuration_schema_for_destination_type(self.type)
self.options.set_schema(schema)
return self.destination.notify(alert, query, user, new_state,
app, host, self.options)
class AlertSubscription(ModelTimestampsMixin, BaseModel):
user = peewee.ForeignKeyField(User)
destination = peewee.ForeignKeyField(NotificationDestination, null=True)
alert = peewee.ForeignKeyField(Alert, related_name="subscriptions")
class Meta:
db_table = 'alert_subscriptions'
indexes = (
(('destination', 'alert'), True),
)
def to_dict(self):
d = {
'id': self.id,
'user': self.user.to_dict(),
'alert_id': self.alert_id
}
if self.destination:
d['destination'] = self.destination.to_dict()
return d
@classmethod
def all(cls, alert_id):
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
def notify(self, alert, query, user, new_state, app, host):
if self.destination:
return self.destination.notify(alert, query, user, new_state,
app, host)
else:
# User email subscription, so create an email destination object
config = {'email': self.user.email}
schema = get_configuration_schema_for_destination_type('email')
options = ConfigurationContainer(json.dumps(config), schema)
destination = get_destination('email', options)
return destination.notify(alert, query, user, new_state,
app, host, options)
all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, Event, NotificationDestination, AlertSubscription, ApiKey)
def init_db(): def init_db():

View File

@@ -148,7 +148,7 @@ def get_query_runner(query_runner_type, configuration):
return query_runner_class(configuration) return query_runner_class(configuration)
def get_configuration_schema_for_type(query_runner_type): def get_configuration_schema_for_query_runner_type(query_runner_type):
query_runner_class = query_runners.get(query_runner_type, None) query_runner_class = query_runners.get(query_runner_type, None)
if query_runner_class is None: if query_runner_class is None:
return None return None

View File

@@ -183,6 +183,19 @@ disabled_query_runners = array_from_string(os.environ.get("REDASH_DISABLED_QUERY
QUERY_RUNNERS = remove(set(disabled_query_runners), distinct(enabled_query_runners + additional_query_runners)) QUERY_RUNNERS = remove(set(disabled_query_runners), distinct(enabled_query_runners + additional_query_runners))
# Destinations
default_destinations = [
'redash.destinations.email',
'redash.destinations.slack',
'redash.destinations.webhook',
'redash.destinations.hipchat',
]
enabled_destinations = array_from_string(os.environ.get("REDASH_ENABLED_DESTINATIONS", ",".join(default_destinations)))
additional_destinations = array_from_string(os.environ.get("REDASH_ADDITIONAL_DESTINATIONS", ""))
DESTINATIONS = distinct(enabled_destinations + additional_destinations)
EVENT_REPORTING_WEBHOOKS = array_from_string(os.environ.get("REDASH_EVENT_REPORTING_WEBHOOKS", "")) EVENT_REPORTING_WEBHOOKS = array_from_string(os.environ.get("REDASH_EVENT_REPORTING_WEBHOOKS", ""))
# Support for Sentry (http://getsentry.com/). Just set your Sentry DSN to enable it: # Support for Sentry (http://getsentry.com/). Just set your Sentry DSN to enable it:

View File

@@ -1,12 +1,7 @@
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
import datetime import datetime
from flask.ext.mail import Message
import hipchat
import requests
from redash.utils import json_dumps
from requests.auth import HTTPBasicAuth
from redash.worker import celery from redash.worker import celery
from redash import utils, mail from redash import utils
from redash import models, settings from redash import models, settings
from .base import BaseTask from .base import BaseTask
@@ -21,8 +16,8 @@ def base_url(org):
return settings.HOST return settings.HOST
@celery.task(name="redash.tasks.check_alerts_for_query", bind=True, base=BaseTask) @celery.task(name="redash.tasks.check_alerts_for_query", base=BaseTask)
def check_alerts_for_query(self, query_id): def check_alerts_for_query(query_id):
from redash.wsgi import app from redash.wsgi import app
logger.debug("Checking query %d for alerts", query_id) logger.debug("Checking query %d for alerts", query_id)
@@ -42,56 +37,10 @@ def check_alerts_for_query(self, query_id):
logger.debug("Skipping notification (previous state was unknown and now it's ok).") logger.debug("Skipping notification (previous state was unknown and now it's ok).")
continue continue
# message = Message host = base_url(alert.query.org)
html = """ for subscription in alert.subscriptions:
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>. try:
""".format(host=base_url(alert.query.org), alert_id=alert.id, query_id=query.id) subscription.notify(alert, query, subscription.user, new_state, app, host)
except Exception as e:
logger.warn("Exception: {}".format(e))
notify_mail(alert, html, new_state, app)
if settings.HIPCHAT_API_TOKEN:
notify_hipchat(alert, html, new_state)
if settings.WEBHOOK_ENDPOINT:
notify_webhook(alert, query, html, new_state)
def notify_hipchat(alert, html, new_state):
try:
if settings.HIPCHAT_API_URL:
hipchat_client = hipchat.HipChat(token=settings.HIPCHAT_API_TOKEN, url=settings.HIPCHAT_API_URL)
else:
hipchat_client = hipchat.HipChat(token=settings.HIPCHAT_API_TOKEN)
message = '[' + new_state.upper() + '] ' + alert.name + '<br />' + html
hipchat_client.message_room(settings.HIPCHAT_ROOM_ID, settings.NAME, message.encode('utf-8', 'ignore'), message_format='html')
except Exception:
logger.exception("hipchat send ERROR.")
def notify_mail(alert, html, new_state, app):
recipients = [s.email for s in alert.subscribers()]
logger.debug("Notifying: %s", recipients)
try:
with app.app_context():
message = Message(recipients=recipients,
subject="[{1}] {0}".format(alert.name.encode('utf-8', 'ignore'), new_state.upper()),
html=html)
mail.send(message)
except Exception:
logger.exception("mail send ERROR.")
def notify_webhook(alert, query, html, new_state):
try:
data = {
'event': 'alert_state_change',
'alert': alert.to_dict(full=False),
'url_base': base_url(query.org)
}
headers = {'Content-Type': 'application/json'}
auth = HTTPBasicAuth(settings.WEBHOOK_USERNAME, settings.WEBHOOK_PASSWORD) if settings.WEBHOOK_USERNAME else None
resp = requests.post(settings.WEBHOOK_ENDPOINT, data=json_dumps(data), auth=auth, headers=headers)
if resp.status_code != 200:
logger.error("webhook send ERROR. status_code => {status}".format(status=resp.status_code))
except Exception:
logger.exception("webhook send ERROR.")

View File

@@ -111,6 +111,18 @@ widget_factory = ModelFactory(redash.models.Widget,
dashboard=dashboard_factory.create, dashboard=dashboard_factory.create,
visualization=visualization_factory.create) visualization=visualization_factory.create)
destination_factory = ModelFactory(redash.models.NotificationDestination,
org=1,
user=user_factory.create,
name='Destination',
type='slack',
options=ConfigurationContainer.from_json('{"url": "https://www.slack.com"}'))
alert_subscription_factory = ModelFactory(redash.models.AlertSubscription,
user=user_factory.create,
destination=destination_factory.create,
alert=alert_factory.create)
class Factory(object): class Factory(object):
def __init__(self): def __init__(self):
@@ -259,3 +271,9 @@ class Factory(object):
} }
args.update(kwargs) args.update(kwargs)
return api_key_factory.create(**args) return api_key_factory.create(**args)
def create_destination(self, **kwargs):
return destination_factory.create(**kwargs)
def create_alert_subscription(self, **kwargs):
return alert_subscription_factory.create(**kwargs)

View File

@@ -33,23 +33,28 @@ class TestAlertResourceGet(BaseTestCase):
class TestAlertListPost(BaseTestCase): class TestAlertListPost(BaseTestCase):
def test_returns_200_if_has_access_to_query(self): def test_returns_200_if_has_access_to_query(self):
query = self.factory.create_query() query = self.factory.create_query()
destination = self.factory.create_destination()
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id, options={})) rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id,
destination_id=destination.id, options={}))
self.assertEqual(rv.status_code, 200) self.assertEqual(rv.status_code, 200)
def test_fails_if_doesnt_have_access_to_query(self): def test_fails_if_doesnt_have_access_to_query(self):
data_source = self.factory.create_data_source(group=self.factory.create_group()) data_source = self.factory.create_data_source(group=self.factory.create_group())
query = self.factory.create_query(data_source=data_source) query = self.factory.create_query(data_source=data_source)
destination = self.factory.create_destination()
rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id, options={})) rv = self.make_request('post', "/api/alerts", data=dict(name='Alert', query_id=query.id,
destination_id=destination.id, options={}))
self.assertEqual(rv.status_code, 403) self.assertEqual(rv.status_code, 403)
class TestAlertSubscriptionListResourcePost(BaseTestCase): class TestAlertSubscriptionListResourcePost(BaseTestCase):
def test_subscribers_user_to_alert(self): def test_subscribers_user_to_alert(self):
alert = self.factory.create_alert() alert = self.factory.create_alert()
destination = self.factory.create_destination()
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id)) rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id), data=dict(destination_id=destination.id))
self.assertEqual(rv.status_code, 200) self.assertEqual(rv.status_code, 200)
self.assertIn(self.factory.user, alert.subscribers()) self.assertIn(self.factory.user, alert.subscribers())
@@ -57,8 +62,9 @@ class TestAlertSubscriptionListResourcePost(BaseTestCase):
data_source = self.factory.create_data_source(group=self.factory.create_group()) data_source = self.factory.create_data_source(group=self.factory.create_group())
query = self.factory.create_query(data_source=data_source) query = self.factory.create_query(data_source=data_source)
alert = self.factory.create_alert(query=query) alert = self.factory.create_alert(query=query)
destination = self.factory.create_destination()
rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id)) rv = self.make_request('post', "/api/alerts/{}/subscriptions".format(alert.id), data=dict(destination_id=destination.id))
self.assertEqual(rv.status_code, 403) self.assertEqual(rv.status_code, 403)
self.assertNotIn(self.factory.user, alert.subscribers()) self.assertNotIn(self.factory.user, alert.subscribers())
@@ -81,18 +87,20 @@ class TestAlertSubscriptionListResourceGet(BaseTestCase):
class TestAlertSubscriptionresourceDelete(BaseTestCase): class TestAlertSubscriptionresourceDelete(BaseTestCase):
def test_only_subscriber_or_admin_can_unsubscribe(self): def test_only_subscriber_or_admin_can_unsubscribe(self):
alert = self.factory.create_alert() subscription = self.factory.create_alert_subscription()
alert = subscription.alert
user = subscription.user
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, subscription.id)
other_user = self.factory.create_user() other_user = self.factory.create_user()
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, other_user.id)
AlertSubscription.create(alert=alert, user=other_user) response = self.make_request('delete', path, user=other_user)
response = self.make_request('delete', path)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = self.make_request('delete', path, user=self.factory.create_admin()) response = self.make_request('delete', path, user=user)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
AlertSubscription.create(alert=alert, user=other_user) subscription_two = AlertSubscription.create(alert=alert, user=other_user)
response = self.make_request('delete', path, user=other_user) path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, subscription_two.id)
response = self.make_request('delete', path, user=self.factory.create_admin())
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)