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/admin_controllers.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_source.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/directives/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/filters.js"></script>
<script src="/scripts/controllers/alerts.js"></script>

View File

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

View File

@@ -115,6 +115,15 @@ angular.module('redash', [
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', {
templateUrl: '/views/users/new.html',
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.alertId = $routeParams.alertId;
@@ -108,69 +109,109 @@
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 {
restrict: 'E',
replace: true,
templateUrl: '/views/alerts/subscribers.html',
templateUrl: '/views/alerts/alert_subscriptions.html',
scope: {
'alertId': '='
},
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) {
return {
restrict: 'E',
replace: true,
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
controller: function ($scope) {
var updateClass = function() {
if ($scope.subscription) {
$scope.class = "fa fa-eye-slash";
} else {
$scope.class = "fa fa-eye";
var destinations = Destination.query().$promise;
var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
$q.all([destinations, subscribers]).then(function(responses) {
var destinations = responses[0];
var subscribers = responses[1];
var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
$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.subscription = _.find($scope.subscribers, function(subscription) {
return (subscription.user.email == currentUser.email);
});
updateClass();
$scope.newSubscription.destination = $scope.destinations[0];
$scope.subscribers = subscribers;
});
$scope.toggleSubscription = function() {
if ($scope.subscription) {
$scope.subscription.$delete(function() {
$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.");
});
$scope.destinationsDisplay = function(destination) {
if (!destination) {
return '';
}
}
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')
.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");
$scope.$parent.pageTitle = "Data Sources";
@@ -24,9 +24,21 @@
$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')
.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>'
}
})
});
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() {
return {
@@ -407,10 +486,12 @@
scope.usersPage = _.string.startsWith($location.path(), '/users');
scope.groupsPage = _.string.startsWith($location.path(), '/groups');
scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
scope.showGroupsLink = currentUser.hasPermission('list_users');
scope.showUsersLink = currentUser.hasPermission('list_users');
scope.showDsLink = currentUser.hasPermission('admin');
scope.showDestinationsLink = currentUser.hasPermission('admin');
}
}
}]);

View File

@@ -567,6 +567,17 @@
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 transformSingle = function(user) {
if (user.groups !== undefined) {
@@ -607,7 +618,7 @@
};
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;
};
@@ -619,7 +630,9 @@
var newData = _.extend({}, data);
if (newData.query_id === undefined) {
newData.query_id = newData.query.id;
newData.destination_id = newData.destinations;
delete newData.query;
delete newData.destinations;
}
return newData;
@@ -656,6 +669,7 @@
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
.factory('DataSource', ['$resource', DataSource])
.factory('Destination', ['$resource', Destination])
.factory('Alert', ['$resource', '$http', Alert])
.factory('AlertSubscription', ['$resource', AlertSubscription])
.factory('Widget', ['$resource', 'Query', Widget])

View File

@@ -657,3 +657,8 @@ div.table-name:hover {
.t-body a.actions.open > a {
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>
</div>
<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>

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>
<div class="row">
<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>
</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': 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': destinationsPage }" ng-if="showDestinationsLink"><a href="destinations">Alert Destinations</a></li>
</ul>
<div ng-transclude>

View File

@@ -10,6 +10,7 @@ from flask_mail import Mail
from redash import settings
from redash.query_runner import import_query_runners
from redash.destinations import import_destinations
__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)
import_query_runners(settings.QUERY_RUNNERS)
import_destinations(settings.DESTINATIONS)
from redash.version_check import reset_new_version_status
reset_new_version_status()

View File

@@ -2,7 +2,7 @@ import json
import click
from flask_script import Manager
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
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)
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)
data_source.options.set_schema(schema)
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'
})
# 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()
@require_permission('list_alerts')
@@ -76,15 +66,24 @@ class AlertListResource(BaseResource):
class AlertSubscriptionListResource(BaseResource):
def post(self, alert_id):
req = request.get_json(True)
alert = models.Alert.get_by_id_and_org(alert_id, self.current_org)
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({
'action': 'subscribe',
'timestamp': int(time.time()),
'object_id': alert_id,
'object_type': 'alert'
'object_type': 'alert',
'destination': req.get('destination_id')
})
return subscription.to_dict()
@@ -99,8 +98,10 @@ class AlertSubscriptionListResource(BaseResource):
class AlertSubscriptionResource(BaseResource):
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({
'action': 'unsubscribe',

View File

@@ -16,6 +16,7 @@ from redash.handlers.visualizations import VisualizationResource
from redash.handlers.widgets import WidgetResource, WidgetListResource
from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \
GroupDataSourceListResource, GroupDataSourceResource
from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource
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(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.utils.configuration import ConfigurationContainer, ValidationError
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
@@ -26,7 +26,7 @@ class DataSourceResource(BaseResource):
data_source = models.DataSource.get_by_id_and_org(data_source_id, self.current_org)
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:
abort(400)
@@ -35,7 +35,7 @@ class DataSourceResource(BaseResource):
data_source.options.update(req['options'])
except ValidationError:
abort(400)
data_source.type = req['type']
data_source.name = req['name']
data_source.save()
@@ -77,7 +77,7 @@ class DataSourceListResource(BaseResource):
if f not in req:
abort(400)
schema = get_configuration_schema_for_type(req['type'])
schema = get_configuration_schema_for_query_runner_type(req['type'])
if schema is None:
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',
'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
for path in settings.STATIC_ASSETS_PATHS:
print path
full_path = safe_join(path, filename)
if os.path.isfile(full_path):
return send_file(full_path, **dict(cache_timeout=cache_timeout, conditional=True))
@@ -80,6 +79,8 @@ rules = ['/admin/<anything>/<whatever>',
'/data_sources/<pk>',
'/users',
'/users/<pk>',
'/destinations',
'/destinations/<pk>',
'/groups',
'/groups/<pk>',
'/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 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.utils import generate_token
from redash.utils.configuration import ConfigurationContainer
@@ -378,7 +379,7 @@ class DataSource(BelongsToOrgMixin, BaseModel):
}
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)
d['options'] = self.options.to_dict(mask_secrets=True)
d['queue_name'] = self.queue_name
@@ -827,29 +828,6 @@ class Alert(ModelTimestampsMixin, BaseModel):
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):
id = peewee.PrimaryKeyField()
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)
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():

View File

@@ -148,7 +148,7 @@ def get_query_runner(query_runner_type, 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)
if query_runner_class is 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))
# 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", ""))
# 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
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 import utils, mail
from redash import utils
from redash import models, settings
from .base import BaseTask
@@ -21,8 +16,8 @@ def base_url(org):
return settings.HOST
@celery.task(name="redash.tasks.check_alerts_for_query", bind=True, base=BaseTask)
def check_alerts_for_query(self, query_id):
@celery.task(name="redash.tasks.check_alerts_for_query", base=BaseTask)
def check_alerts_for_query(query_id):
from redash.wsgi import app
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).")
continue
# message = Message
html = """
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
""".format(host=base_url(alert.query.org), alert_id=alert.id, query_id=query.id)
host = base_url(alert.query.org)
for subscription in alert.subscriptions:
try:
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,
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):
def __init__(self):
@@ -259,3 +271,9 @@ class Factory(object):
}
args.update(kwargs)
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):
def test_returns_200_if_has_access_to_query(self):
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)
def test_fails_if_doesnt_have_access_to_query(self):
data_source = self.factory.create_data_source(group=self.factory.create_group())
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)
class TestAlertSubscriptionListResourcePost(BaseTestCase):
def test_subscribers_user_to_alert(self):
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.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())
query = self.factory.create_query(data_source=data_source)
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.assertNotIn(self.factory.user, alert.subscribers())
@@ -81,18 +87,20 @@ class TestAlertSubscriptionListResourceGet(BaseTestCase):
class TestAlertSubscriptionresourceDelete(BaseTestCase):
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()
path = '/api/alerts/{}/subscriptions/{}'.format(alert.id, other_user.id)
AlertSubscription.create(alert=alert, user=other_user)
response = self.make_request('delete', path)
response = self.make_request('delete', path, user=other_user)
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)
AlertSubscription.create(alert=alert, user=other_user)
response = self.make_request('delete', path, user=other_user)
subscription_two = AlertSubscription.create(alert=alert, 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)