From 3ac7f02aea43c3aedb3ba9b6d2c3cdb59784b54f Mon Sep 17 00:00:00 2001 From: Alex DeBrie Date: Thu, 18 Feb 2016 20:44:49 +0000 Subject: [PATCH 01/38] Add NotificationDestination model and handlers; Add BaseNotification class --- .../0023_add_notification_destination.py | 13 +++ redash/__init__.py | 2 + redash/destinations/__init__.py | 91 +++++++++++++++ redash/destinations/slack.py | 21 ++++ redash/handlers/api.py | 5 +- redash/handlers/destinations.py | 83 ++++++++++++++ redash/models.py | 105 +++++++++++++++++- redash/settings.py | 10 ++ 8 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 migrations/0023_add_notification_destination.py create mode 100644 redash/destinations/__init__.py create mode 100644 redash/destinations/slack.py create mode 100644 redash/handlers/destinations.py diff --git a/migrations/0023_add_notification_destination.py b/migrations/0023_add_notification_destination.py new file mode 100644 index 000000000..685d32abc --- /dev/null +++ b/migrations/0023_add_notification_destination.py @@ -0,0 +1,13 @@ +from redash.models import db, NotificationDestination, NotificationDestinationGroup + +if __name__ == '__main__': + with db.database.transaction(): + + if not NotificationDestination.table_exists(): + NotificationDestination.create_table() + + if not NotificationDestinationGroup.table_exists(): + NotificationDestinationGroup.create_table() + + db.close_db(None) + diff --git a/redash/__init__.py b/redash/__init__.py index a4012fde3..92ace5bf1 100644 --- a/redash/__init__.py +++ b/redash/__init__.py @@ -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.10.0' @@ -51,6 +52,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() diff --git a/redash/destinations/__init__.py b/redash/destinations/__init__.py new file mode 100644 index 000000000..a1daf49f5 --- /dev/null +++ b/redash/destinations/__init__.py @@ -0,0 +1,91 @@ +import logging +import json + +import jsonschema +from jsonschema import ValidationError +from redash import settings + +logger = logging.getLogger(__name__) + +__all__ = [ + 'ValidationError', + 'BaseDestination', + 'register', + 'get_destination', + 'import_destinations' +] + + +class BaseDestination(object): + def __init__(self, configuration): + jsonschema.validate(configuration, self.configuration_schema()) + self.configuration = configuration + + @classmethod + def name(cls): + return cls.__name__ + + @classmethod + def type(cls): + return cls.__name__.lower() + + @classmethod + def enabled(cls): + return True + + @classmethod + def configuration_schema(cls): + return {} + + def notify(self, query): + raise NotImplementedError() + + @classmethod + def to_dict(cls): + return { + 'name': cls.name(), + 'type': cls.type(), + '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_json): + destination_class = destinations.get(destination_type, None) + if destination_class is None: + return None + + return destination_class(json.loads(configuration_json)) + + +def validate_configuration(destination_type, configuration_json): + destination_class = destinations.get(destination_type, None) + if destination_class is None: + return False + + try: + if isinstance(configuration_json, basestring): + configuration = json.loads(configuration_json) + else: + configuration = configuration_json + jsonschema.validate(configuration, destination_class.configuration_schema()) + except (ValidationError, ValueError): + return False + + return True + + +def import_destinations(destination_imports): + for destination_import in destination_imports: + __import__(destination_import) diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py new file mode 100644 index 000000000..f81fd33ee --- /dev/null +++ b/redash/destinations/slack.py @@ -0,0 +1,21 @@ +from redash.destinations import * + + +class Slack(BaseDestination): + + @classmethod + def configuration_schema(cls): + return { + 'type': 'object', + 'properties': { + 'url': { + 'type': 'string', + 'title': 'Slack webhook URL' + } + } + } + + def notify(self, query): + pass + +register(Slack) diff --git a/redash/handlers/api.py b/redash/handlers/api.py index ebd36903f..87ab656eb 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -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): @@ -85,4 +86,6 @@ api.add_org_resource(VisualizationResource, '/api/visualizations/', endpoint='widget') - +api.add_org_resource(DestinationTypeListResource, '/api/destinations/types', endpoint='destination_types') +api.add_org_resource(DestinationResource, '/api/destinations/', endpoint='destination') +api.add_org_resource(DestinationListResource, '/api/destinations', endpoint='destinations') diff --git a/redash/handlers/destinations.py b/redash/handlers/destinations.py new file mode 100644 index 000000000..407035931 --- /dev/null +++ b/redash/handlers/destinations.py @@ -0,0 +1,83 @@ +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, validate_configuration +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) + + destination.replace_secret_placeholders(req['options']) + + if not validate_configuration(req['type'], req['options']): + abort(400) + + destination.name = req['name'] + destination.options = json.dumps(req['options']) + + 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): + if self.current_user.has_permission('admin'): + destinations = models.NotificationDestination.all(self.current_org) + else: + destinations = models.NotificationDestination.all(self.current_org, groups=self.current_user.groups) + + response = {} + for ds in destinations: + if ds.id in response: + continue + + d = ds.to_dict() + d['view_only'] = all(project(ds.groups, self.current_user.groups).values()) + 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) + + if not validate_configuration(req['type'], req['options']): + abort(400) + + destination = models.NotificationDestination.create_with_group(org=self.current_org, + name=req['name'], + type=req['type'], options=json.dumps(req['options'])) + + return destination.to_dict(all=True) diff --git a/redash/models.py b/redash/models.py index 3d0d459e6..d2c1c353c 100644 --- a/redash/models.py +++ b/redash/models.py @@ -17,6 +17,7 @@ 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.destinations import get_destination from redash.metrics.database import MeteredPostgresqlExtDatabase, MeteredModel from redash.utils import generate_token from redash.utils.configuration import ConfigurationContainer @@ -1068,7 +1069,109 @@ 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") + name = peewee.CharField() + type = peewee.CharField() + options = peewee.TextField() + created_at = DateTimeTZField(default=datetime.datetime.now) + + class Meta: + db_table = 'notification_destinations' + + indexes = ( + (('org', 'name'), True), + ) + + def to_dict(self, all=False, with_permissions=False): + d = { + 'id': self.id, + 'name': self.name, + 'type': self.type, + } + + if all: + d['options'] = self.configuration + d['groups'] = self.groups + + if with_permissions: + d['view_only'] = self.notification_destination_groups.view_only + + return d + + def __unicode__(self): + return self.name + + @classmethod + def create_with_group(cls, *args, **kwargs): + notification_destination = cls.create(*args, **kwargs) + NotificationDestinationGroup.create( + notification_destination=notification_destination, + group=notification_destination.org.default_group + ) + return notification_destination + + @property + def configuration(self): + configuration = json.loads(self.options) + schema = self.destination.configuration_schema() + for prop in schema.get('secret', []): + if prop in configuration and configuration[prop]: + configuration[prop] = self.SECRET_PLACEHOLDER + + return configuration + + def replace_secret_placeholders(self, configuration): + current_configuration = json.loads(self.options) + schema = self.destination.configuration_schema() + for prop in schema.get('secret', []): + if prop in configuration and configuration[prop] == self.SECRET_PLACEHOLDER: + configuration[prop] = current_configuration[prop] + + def add_group(self, group, view_only=False): + ndg = NotificationDestinationGroup.create(group=group, notification_destination=self, view_only=view_only) + setattr(self, 'notification_destination_groups', ndg) + + def remove_group(self, group): + NotificationDestinationGroup.delete().where(NotificationDestinationGroup.group==group, NotificationDestinationGroup.notification_destination==self).execute() + + def update_group_permission(self, group, view_only): + ndg = NotificationDestinationGroup.get(NotificationDestinationGroup.group==group, NotificationDestinationGroup.notification_destination==self) + ndg.view_only = view_only + ndg.save() + setattr(self, 'notification_destination_groups', dsg) + + @property + def destination(self): + return get_destination(self.type, self.options) + + @classmethod + def all(cls, org, groups=None): + notification_destinations = cls.select().where(cls.org==org).order_by(cls.id.asc()) + + if groups: + notification_destinations = notification_destinations.join(NotificationDestinationGroup).where(NotificationDestinationGroup.group << groups) + + return notification_destinations + + @property + def groups(self): + groups = NotificationDestinationsGroup.select().where(NotificationDestinationGroup.notification_destination==self) + return dict(map(lambda g: (g.group_id, g.view_only), groups)) + + +class NotificationDestinationGroup(BaseModel): + notification_destination = peewee.ForeignKeyField(NotificationDestination) + group = peewee.ForeignKeyField(Group, related_name="notification_destinations") + view_only = peewee.BooleanField(default=False) + + class Meta: + db_table = "notification_destination_groups" + + +all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, AlertSubscription, Dashboard, Visualization, Widget, Event, ApiKey, NotificationDestination, NotificationDestinationGroup) def init_db(): diff --git a/redash/settings.py b/redash/settings.py index 8c5ac2f83..14bd3387d 100644 --- a/redash/settings.py +++ b/redash/settings.py @@ -178,6 +178,16 @@ additional_query_runners = array_from_string(os.environ.get("REDASH_ADDITIONAL_Q QUERY_RUNNERS = distinct(enabled_query_runners + additional_query_runners) +# Destinations +default_destinations = [ + 'redash.destinations.slack', +] + +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: From 53f8f1de3b994a7d12a54f8a488758fae2bd6550 Mon Sep 17 00:00:00 2001 From: Alex DeBrie Date: Fri, 19 Feb 2016 13:33:29 +0000 Subject: [PATCH 02/38] Fix typo --- redash/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redash/models.py b/redash/models.py index d2c1c353c..848d12b70 100644 --- a/redash/models.py +++ b/redash/models.py @@ -1158,7 +1158,7 @@ class NotificationDestination(BelongsToOrgMixin, BaseModel): @property def groups(self): - groups = NotificationDestinationsGroup.select().where(NotificationDestinationGroup.notification_destination==self) + groups = NotificationDestinationGroup.select().where(NotificationDestinationGroup.notification_destination==self) return dict(map(lambda g: (g.group_id, g.view_only), groups)) From 3844483776d5a915cc90ae25c9d88a5f288cfd92 Mon Sep 17 00:00:00 2001 From: Alex DeBrie Date: Fri, 19 Feb 2016 14:16:41 +0000 Subject: [PATCH 03/38] Add destination elements to rd_ui --- rd_ui/app/app_layout.html | 2 + rd_ui/app/index.html | 2 - rd_ui/app/scripts/app.js | 9 +++ rd_ui/app/scripts/controllers/destinations.js | 47 +++++++++++ rd_ui/app/scripts/controllers/users.js | 43 ++++++++++ .../directives/destination_directives.js | 80 +++++++++++++++++++ rd_ui/app/scripts/services/resources.js | 16 +++- rd_ui/app/views/app_header.html | 3 + rd_ui/app/views/destinations/edit.html | 11 +++ rd_ui/app/views/destinations/form.html | 20 +++++ rd_ui/app/views/destinations/list.html | 18 +++++ rd_ui/app/views/groups/show.html | 3 +- rd_ui/app/views/groups/show_data_sources.html | 1 + rd_ui/app/views/groups/show_destinations.html | 58 ++++++++++++++ redash/handlers/destinations.py | 1 - 15 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 rd_ui/app/scripts/controllers/destinations.js create mode 100644 rd_ui/app/scripts/directives/destination_directives.js create mode 100644 rd_ui/app/views/destinations/edit.html create mode 100644 rd_ui/app/views/destinations/form.html create mode 100644 rd_ui/app/views/destinations/list.html create mode 100644 rd_ui/app/views/groups/show_destinations.html diff --git a/rd_ui/app/app_layout.html b/rd_ui/app/app_layout.html index 4968d8142..c173791d8 100644 --- a/rd_ui/app/app_layout.html +++ b/rd_ui/app/app_layout.html @@ -68,6 +68,7 @@ + @@ -84,6 +85,7 @@ + diff --git a/rd_ui/app/index.html b/rd_ui/app/index.html index 1543811de..4543fe5b7 100644 --- a/rd_ui/app/index.html +++ b/rd_ui/app/index.html @@ -1,7 +1,5 @@ {% extends 'app_layout.html' %} - {% block content %} {% endblock %} - diff --git a/rd_ui/app/scripts/app.js b/rd_ui/app/scripts/app.js index 7236ad775..f9f6492cf 100644 --- a/rd_ui/app/scripts/app.js +++ b/rd_ui/app/scripts/app.js @@ -107,6 +107,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' diff --git a/rd_ui/app/scripts/controllers/destinations.js b/rd_ui/app/scripts/controllers/destinations.js new file mode 100644 index 000000000..5a4f3122f --- /dev/null +++ b/rd_ui/app/scripts/controllers/destinations.js @@ -0,0 +1,47 @@ +(function () { + var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) { + Events.record(currentUser, "view", "page", "admin/destinations"); + $scope.$parent.pageTitle = "Destinations"; + + $scope.destinations = Destination.query(); + + $scope.openDestination = function(destination) { + $location.path('/destinations/' + destination.id); + }; + + $scope.deleteDestination = function(event, destination) { + event.stopPropagation(); + Events.record(currentUser, "delete", "destination", destination.id); + destination.$delete(function(resource) { + growl.addSuccessMessage("Destination deleted successfully."); + this.$parent.destinations = _.without(this.destinations, resource); + }.bind(this), function(httpResponse) { + console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data); + growl.addErrorMessage("Failed to delete destination."); + }); + } + }; + + var DestinationCtrl = function ($scope, $routeParams, $http, $location, 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(); + } + }); + }; + + angular.module('redash.controllers') + .controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl]) + .controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'Destination', DestinationCtrl]) +})(); diff --git a/rd_ui/app/scripts/controllers/users.js b/rd_ui/app/scripts/controllers/users.js index 915278366..b7b7886e7 100644 --- a/rd_ui/app/scripts/controllers/users.js +++ b/rd_ui/app/scripts/controllers/users.js @@ -153,6 +153,48 @@ }; } + var GroupDestinationsCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, Destination) { + Events.record(currentUser, "view", "group_destinations", $scope.groupId); + $scope.group = Group.get({id: $routeParams.groupId}); + $scope.destinations = Group.destinations({id: $routeParams.groupId}); + $scope.newDestination= {}; + + $scope.findDestination = function(search) { + if ($scope.foundDestinations === undefined) { + Destination.query(function(destinations) { + var existingIds = _.map($scope.destinations, function(m) { return m.id; }); + $scope.foundDestinations = _.filter(destinations, function(d) { return !_.contains(existingIds, d.id); }); + }); + } + }; + + $scope.addDestination = function(destination) { + // Clear selection, to clear up the input control. + $scope.newDestination.selected = undefined; + + $http.post('api/groups/' + $routeParams.groupId + '/destinations', {'destination_id': destination.id}).success(function(user) { + destination.view_only = false; + $scope.destinations.unshift(destination); + + if ($scope.foundDestinations) { + $scope.foundDestinations = _.filter($scope.foundDestinations, function(d) { return d != destination; }); + } + }); + }; + + $scope.changePermission = function(destination, viewOnly) { + $http.post('api/groups/' + $routeParams.groupId + '/destinations/' + destination.id, {view_only: viewOnly}).success(function() { + destination.view_only = viewOnly; + }); + }; + + $scope.removeDestination = function(destination) { + $http.delete('api/groups/' + $routeParams.groupId + '/destinations/' + destination.id).success(function() { + $scope.destinations = _.filter($scope.destinations, function(d) { return destination != d; }); + }); + }; + } + var GroupCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, User) { Events.record(currentUser, "view", "group", $scope.groupId); $scope.group = Group.get({id: $routeParams.groupId}); @@ -352,6 +394,7 @@ .directive('usersNav', ['$location', usersNav]) .controller('GroupCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'User', GroupCtrl]) .controller('GroupDataSourcesCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'DataSource', GroupDataSourcesCtrl]) + .controller('GroupDestinationsCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'Destination', GroupDestinationsCtrl]) .controller('UsersCtrl', ['$scope', '$location', 'growl', 'Events', 'User', UsersCtrl]) .controller('UserCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'User', UserCtrl]) .controller('NewUserCtrl', ['$scope', '$location', 'growl', 'Events', 'User', NewUserCtrl]) diff --git a/rd_ui/app/scripts/directives/destination_directives.js b/rd_ui/app/scripts/directives/destination_directives.js new file mode 100644 index 000000000..738a7885e --- /dev/null +++ b/rd_ui/app/scripts/directives/destination_directives.js @@ -0,0 +1,80 @@ +(function () { + 'use strict'; + + var directives = angular.module('redash.directives'); + + directives.directive('destinationForm', ['$http', 'growl', function ($http, growl) { + return { + restrict: 'E', + replace: true, + templateUrl: '/views/destinations/form.html', + scope: { + 'destination': '=' + }, + link: function ($scope) { + var setType = function(types) { + if ($scope.destination.type === undefined) { + $scope.destination.type = types[0].type; + return types[0]; + } + + $scope.type = _.find(types, function (t) { + return t.type == $scope.destination.type; + }); + }; + + $scope.files = {}; + + $scope.$watchCollection('files', function() { + _.each($scope.files, function(v, k) { + if (v) { + $scope.dataSource.options[k] = v.base64; + } + }); + }); + + + $http.get('api/destinations/types').success(function (types) { + setType(types); + + $scope.destinationTypes = 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('destination.type', function(current, prev) { + if (prev !== current) { + if (prev !== undefined) { + $scope.destination.options = {}; + } + setType($scope.destinationTypes); + } + }); + + $scope.saveChanges = function() { + $scope.destination.$save(function() { + growl.addSuccessMessage("Saved."); + }, function() { + growl.addErrorMessage("Failed saving."); + }); + } + } + } + }]); +})(); diff --git a/rd_ui/app/scripts/services/resources.js b/rd_ui/app/scripts/services/resources.js index a048dcb75..e82667e60 100644 --- a/rd_ui/app/scripts/services/resources.js +++ b/rd_ui/app/scripts/services/resources.js @@ -556,6 +556,18 @@ return DataSourceResource; }; + var Destination = function ($resource) { + var actions = { + 'get': {'method': 'GET', 'cache': false, 'isArray': false}, + 'query': {'method': 'GET', 'cache': false, 'isArray': true}, + 'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/destinations/:id/schema'} + }; + + var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions); + + return DestinationResource; + }; + var User = function ($resource, $http) { var transformSingle = function(user) { if (user.groups !== undefined) { @@ -589,7 +601,8 @@ 'get': {'method': 'GET', 'cache': false, 'isArray': false}, 'query': {'method': 'GET', 'cache': false, 'isArray': true}, 'members': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/members'}, - 'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/data_sources'} + 'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/data_sources'}, + 'destinations': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/destinations'} }; var resource = $resource('api/groups/:id', {id: '@id'}, actions); return resource; @@ -645,6 +658,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]) diff --git a/rd_ui/app/views/app_header.html b/rd_ui/app/views/app_header.html index 86a702a98..aa4dbf120 100644 --- a/rd_ui/app/views/app_header.html +++ b/rd_ui/app/views/app_header.html @@ -50,6 +50,9 @@
From f8120284d504510d970fbb44f07938e0b411e904 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Mon, 30 May 2016 14:39:01 +0300 Subject: [PATCH 36/38] WIP: updated look and feel --- rd_ui/app/scripts/controllers/alerts.js | 3 +- .../views/alerts/destinationsubscribers.html | 43 ++++++++----------- rd_ui/app/views/alerts/edit.html | 10 +++-- rd_ui/app/views/alerts/usersubscribers.html | 15 ++++--- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/rd_ui/app/scripts/controllers/alerts.js b/rd_ui/app/scripts/controllers/alerts.js index 83b73a1e6..39798e97f 100644 --- a/rd_ui/app/scripts/controllers/alerts.js +++ b/rd_ui/app/scripts/controllers/alerts.js @@ -47,6 +47,7 @@ }; var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) { + $scope.selectedTab = 'users'; $scope.$parent.pageTitle = "Alerts"; $scope.alertId = $routeParams.alertId; @@ -178,7 +179,7 @@ return { restrict: 'E', replace: true, - template: '', + template: '', controller: function ($scope) { var updateMessage = function() { if ($scope.subscription) { diff --git a/rd_ui/app/views/alerts/destinationsubscribers.html b/rd_ui/app/views/alerts/destinationsubscribers.html index f7f617862..3efad2e80 100644 --- a/rd_ui/app/views/alerts/destinationsubscribers.html +++ b/rd_ui/app/views/alerts/destinationsubscribers.html @@ -1,27 +1,20 @@ -
-
- Destination subscriptions -

Destination subscriptions will send a notification to the configured destination

-
-

Add a destination subscription

-
- - {{$select.selected.name}} - - - - -
-
- -
-
-
- -
- - -
-
+
+
+ + + {{$select.selected.name}} + + + + + +
+ +
+ +
+ +
+
diff --git a/rd_ui/app/views/alerts/edit.html b/rd_ui/app/views/alerts/edit.html index 6daf61e7a..f10915a87 100644 --- a/rd_ui/app/views/alerts/edit.html +++ b/rd_ui/app/views/alerts/edit.html @@ -62,9 +62,13 @@
-

Subscriptions

- - + + + +
diff --git a/rd_ui/app/views/alerts/usersubscribers.html b/rd_ui/app/views/alerts/usersubscribers.html index 84e19f54a..f3fe6d326 100644 --- a/rd_ui/app/views/alerts/usersubscribers.html +++ b/rd_ui/app/views/alerts/usersubscribers.html @@ -1,8 +1,11 @@ -
-
-

-
-

User subscriptions will send a notification to your email

+
+
{{s.id}} -
+
+
+ +
+
+ Subscribed users will receive alert notifications by email. +
From 4d6599e0ead118add7a369b05e16cbfa6aba10cd Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Sun, 5 Jun 2016 15:51:49 +0300 Subject: [PATCH 37/38] WIP --- rd_ui/app/scripts/controllers/alerts.js | 35 +++++++++++++++---- .../views/alerts/destinationsubscribers.html | 10 +++--- rd_ui/app/views/alerts/edit.html | 9 ++--- rd_ui/app/views/alerts/usersubscribers.html | 4 +-- rd_ui/app/views/directives/dynamic_form.html | 4 +-- redash/destinations/hipchat.py | 5 ++- redash/destinations/slack.py | 3 +- redash/destinations/webhook.py | 2 -- 8 files changed, 43 insertions(+), 29 deletions(-) diff --git a/rd_ui/app/scripts/controllers/alerts.js b/rd_ui/app/scripts/controllers/alerts.js index 39798e97f..2b86cda80 100644 --- a/rd_ui/app/scripts/controllers/alerts.js +++ b/rd_ui/app/scripts/controllers/alerts.js @@ -126,13 +126,13 @@ $scope.enabled = !clientConfig.mailSettingsMissing; $scope.subscribers = AlertSubscription.query({alertId: $scope.alertId}, function(subscriptions) { - $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination === "undefined"; }); + $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination === "undefined"; }); }); } } }]); - angular.module('redash.directives').directive('destinationSubscribers', ['AlertSubscription', 'Destination', 'growl', function (AlertSubscription, Destination, growl) { + angular.module('redash.directives').directive('destinationSubscribers', ['$sce', 'AlertSubscription', 'Destination', 'growl', function ($sce, AlertSubscription, Destination, growl) { return { restrict: 'E', replace: true, @@ -143,15 +143,37 @@ controller: function ($scope) { $scope.subscription = {}; $scope.subscribers = []; - $scope.destinations = Destination.query(); + $scope.destinations = []; + + Destination.query(function(destinations) { + $scope.destinations = destinations; + destinations.unshift({name: currentUser.name + ' (Email)', icon: 'fa-envelope', type: 'user'}); + $scope.subscription.destination = destinations[0]; + }); $scope.destinationsDisplay = function(destination) { - return ' ' + destination.name + if (destination.destination) { + destination = destination; + } + + if (destination.user) { + destination = { + name: destination.user.name + ' (Email)', + icon: 'fa-envelope', + type: 'user' + }; + } + + if (!destination) { + return ''; + } + return $sce.trustAsHtml(' ' + destination.name); }; $scope.subscribers = AlertSubscription.query({alertId: $scope.alertId}, function(subscriptions) { - $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination !== "undefined"; }); + // $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination !== "undefined"; }); }); + $scope.saveSubscriber = function() { $scope.sub = new AlertSubscription({alert_id: $scope.alertId, destination_id: $scope.subscription.destination.id}); $scope.sub.$save(function() { @@ -179,7 +201,7 @@ return { restrict: 'E', replace: true, - template: '', + template: '', controller: function ($scope) { var updateMessage = function() { if ($scope.subscription) { @@ -210,6 +232,7 @@ $scope.subscription = new AlertSubscription({alert_id: $scope.alertId}); $scope.subscription.$save(function() { $scope.subscribers.push($scope.subscription); + console.log($scope.subscribers); updateMessage(); }, function() { growl.addErrorMessage("Unsubscription failed."); diff --git a/rd_ui/app/views/alerts/destinationsubscribers.html b/rd_ui/app/views/alerts/destinationsubscribers.html index 3efad2e80..eb3a6c4f4 100644 --- a/rd_ui/app/views/alerts/destinationsubscribers.html +++ b/rd_ui/app/views/alerts/destinationsubscribers.html @@ -1,20 +1,18 @@
- - {{$select.selected.name}} + - +
-
- - + +
diff --git a/rd_ui/app/views/alerts/edit.html b/rd_ui/app/views/alerts/edit.html index f10915a87..028401bcd 100644 --- a/rd_ui/app/views/alerts/edit.html +++ b/rd_ui/app/views/alerts/edit.html @@ -62,13 +62,10 @@
- + Notifications - - + +
diff --git a/rd_ui/app/views/alerts/usersubscribers.html b/rd_ui/app/views/alerts/usersubscribers.html index f3fe6d326..465a297c2 100644 --- a/rd_ui/app/views/alerts/usersubscribers.html +++ b/rd_ui/app/views/alerts/usersubscribers.html @@ -1,8 +1,8 @@
-
+
{{s.id}}
-
+
diff --git a/rd_ui/app/views/directives/dynamic_form.html b/rd_ui/app/views/directives/dynamic_form.html index dcec14184..bd7b76d83 100644 --- a/rd_ui/app/views/directives/dynamic_form.html +++ b/rd_ui/app/views/directives/dynamic_form.html @@ -1,4 +1,4 @@ -
+
@@ -16,7 +16,7 @@ base-sixty-four-input ng-if="input.type === 'file'">
- + diff --git a/redash/destinations/hipchat.py b/redash/destinations/hipchat.py index 024330cd3..0c2596ef9 100644 --- a/redash/destinations/hipchat.py +++ b/redash/destinations/hipchat.py @@ -5,8 +5,7 @@ from redash import settings from redash.destinations import * -class Hipchat(BaseDestination): - +class HipChat(BaseDestination): @classmethod def configuration_schema(cls): return { @@ -45,4 +44,4 @@ class Hipchat(BaseDestination): logging.exception("hipchat send ERROR.") -register(Hipchat) +register(HipChat) diff --git a/redash/destinations/slack.py b/redash/destinations/slack.py index 552a91299..89b53c37b 100644 --- a/redash/destinations/slack.py +++ b/redash/destinations/slack.py @@ -6,7 +6,6 @@ from redash.destinations import * class Slack(BaseDestination): - @classmethod def configuration_schema(cls): return { @@ -14,7 +13,7 @@ class Slack(BaseDestination): 'properties': { 'url': { 'type': 'string', - 'title': 'Slack webhook URL' + 'title': 'Slack Webhook URL' } } } diff --git a/redash/destinations/webhook.py b/redash/destinations/webhook.py index a6b7d6fc8..952a2584f 100644 --- a/redash/destinations/webhook.py +++ b/redash/destinations/webhook.py @@ -2,13 +2,11 @@ import logging import requests from requests.auth import HTTPBasicAuth -from redash import models, mail from redash.destinations import * from redash.utils import json_dumps class Webhook(BaseDestination): - @classmethod def configuration_schema(cls): return { From eed5485080b5f7968c86576402523142aac2411b Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 7 Jun 2016 15:10:11 +0300 Subject: [PATCH 38/38] Update Alerts/subscriptions UI for new look and feel. --- rd_ui/app/scripts/controllers/alerts.js | 157 +++++++----------- .../app/views/alerts/alert_subscriptions.html | 27 +++ .../views/alerts/destinationsubscribers.html | 18 -- rd_ui/app/views/alerts/edit.html | 5 +- rd_ui/app/views/alerts/usersubscribers.html | 11 -- rd_ui/app/views/app_header.html | 3 - rd_ui/app/views/destinations/form.html | 20 --- rd_ui/app/views/destinations/list.html | 2 +- .../app/views/directives/settings_screen.html | 2 +- redash/handlers/alerts.py | 3 +- 10 files changed, 94 insertions(+), 154 deletions(-) create mode 100644 rd_ui/app/views/alerts/alert_subscriptions.html delete mode 100644 rd_ui/app/views/alerts/destinationsubscribers.html delete mode 100644 rd_ui/app/views/alerts/usersubscribers.html delete mode 100644 rd_ui/app/views/destinations/form.html diff --git a/rd_ui/app/scripts/controllers/alerts.js b/rd_ui/app/scripts/controllers/alerts.js index 2b86cda80..342792785 100644 --- a/rd_ui/app/scripts/controllers/alerts.js +++ b/rd_ui/app/scripts/controllers/alerts.js @@ -112,51 +112,48 @@ }; - angular.module('redash.directives').directive('userSubscribers', ['AlertSubscription', 'growl', function (AlertSubscription, growl) { + 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/userSubscribers.html', + templateUrl: '/views/alerts/alert_subscriptions.html', scope: { 'alertId': '=' }, controller: function ($scope) { - $scope.subscription = {}; - $scope.subscribers = []; - $scope.enabled = !clientConfig.mailSettingsMissing; - - $scope.subscribers = AlertSubscription.query({alertId: $scope.alertId}, function(subscriptions) { - $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination === "undefined"; }); - }); - } - } - }]); - - angular.module('redash.directives').directive('destinationSubscribers', ['$sce', 'AlertSubscription', 'Destination', 'growl', function ($sce, AlertSubscription, Destination, growl) { - return { - restrict: 'E', - replace: true, - templateUrl: '/views/alerts/destinationSubscribers.html', - scope: { - 'alertId': '=' - }, - controller: function ($scope) { - $scope.subscription = {}; + $scope.newSubscription = {}; $scope.subscribers = []; $scope.destinations = []; + $scope.currentUser = currentUser; - Destination.query(function(destinations) { - $scope.destinations = destinations; - destinations.unshift({name: currentUser.name + ' (Email)', icon: 'fa-envelope', type: 'user'}); - $scope.subscription.destination = destinations[0]; + 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.newSubscription.destination = $scope.destinations[0]; + $scope.subscribers = subscribers; }); $scope.destinationsDisplay = function(destination) { - if (destination.destination) { - destination = destination; + if (!destination) { + return ''; } - if (destination.user) { + if (destination.destination) { + destination = destination.destination; + } else if (destination.user) { destination = { name: destination.user.name + ' (Email)', icon: 'fa-envelope', @@ -164,81 +161,51 @@ }; } - if (!destination) { - return ''; - } return $sce.trustAsHtml(' ' + destination.name); }; - $scope.subscribers = AlertSubscription.query({alertId: $scope.alertId}, function(subscriptions) { - // $scope.subscribers = _.filter(subscriptions, function(subscription) { return typeof subscription.destination !== "undefined"; }); - }); - $scope.saveSubscriber = function() { - $scope.sub = new AlertSubscription({alert_id: $scope.alertId, destination_id: $scope.subscription.destination.id}); - $scope.sub.$save(function() { - growl.addSuccessMessage("Subscribed."); - $scope.subscribers.push($scope.sub); - }, function(response) { - growl.addErrorMessage("Failed saving subscription."); - }); + 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) { - $scope.sub = new AlertSubscription({alert_id: subscriber.alert_id, id: subscriber.id}); - $scope.sub.$delete(function() { - growl.addSuccessMessage("Unsubscribed"); - $scope.subscribers = _.without($scope.subscribers, subscriber); - }, function() { - growl.addErrorMessage("Failed unsubscribing."); - }); - }; - } - } - }]); + var destination = subscriber.destination; + var user = subscriber.user; - angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) { - return { - restrict: 'E', - replace: true, - template: '', - controller: function ($scope) { - var updateMessage = function() { - if ($scope.subscription) { - $scope.message = "Unsubscribe"; - } else { - $scope.message = "Subscribe"; - } - } + 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}}); + } - $scope.subscribers.$promise.then(function() { - $scope.subscription = _.find($scope.subscribers, function(subscription) { - return (subscription.user.email == currentUser.email); + if ($scope.destinations.length == 1) { + $scope.newSubscription.destination = $scope.destinations[0]; + } + + }, function () { + growl.addErrorMessage("Failed unsubscribing."); }); - - updateMessage(); - }); - - $scope.toggleSubscription = function() { - if ($scope.subscription) { - $scope.subscription.$delete(function() { - $scope.subscribers = _.without($scope.subscribers, $scope.subscription); - $scope.subscription = undefined; - updateMessage(); - }, function() { - growl.addErrorMessage("Failed saving subscription."); - }); - } else { - $scope.subscription = new AlertSubscription({alert_id: $scope.alertId}); - $scope.subscription.$save(function() { - $scope.subscribers.push($scope.subscription); - console.log($scope.subscribers); - updateMessage(); - }, function() { - growl.addErrorMessage("Unsubscription failed."); - }); - } - } + }; } } }]); diff --git a/rd_ui/app/views/alerts/alert_subscriptions.html b/rd_ui/app/views/alerts/alert_subscriptions.html new file mode 100644 index 000000000..4d3d0a246 --- /dev/null +++ b/rd_ui/app/views/alerts/alert_subscriptions.html @@ -0,0 +1,27 @@ +
+

Notifications

+ +
+ + + + + + +
+
+ + + Create New Destination + +
+ +
+ +
+
+ + +
+
+
diff --git a/rd_ui/app/views/alerts/destinationsubscribers.html b/rd_ui/app/views/alerts/destinationsubscribers.html deleted file mode 100644 index eb3a6c4f4..000000000 --- a/rd_ui/app/views/alerts/destinationsubscribers.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
- - - - - - - -
- -
-
- - -
-
-
diff --git a/rd_ui/app/views/alerts/edit.html b/rd_ui/app/views/alerts/edit.html index 028401bcd..b026548b5 100644 --- a/rd_ui/app/views/alerts/edit.html +++ b/rd_ui/app/views/alerts/edit.html @@ -62,10 +62,7 @@
- Notifications - - - +
diff --git a/rd_ui/app/views/alerts/usersubscribers.html b/rd_ui/app/views/alerts/usersubscribers.html deleted file mode 100644 index 465a297c2..000000000 --- a/rd_ui/app/views/alerts/usersubscribers.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- {{s.id}} -
-
- -
-
- Subscribed users will receive alert notifications by email. -
-
diff --git a/rd_ui/app/views/app_header.html b/rd_ui/app/views/app_header.html index 713b70446..7648dd5b4 100644 --- a/rd_ui/app/views/app_header.html +++ b/rd_ui/app/views/app_header.html @@ -56,9 +56,6 @@
diff --git a/redash/handlers/alerts.py b/redash/handlers/alerts.py index a2dee9ff1..8b066d11a 100644 --- a/redash/handlers/alerts.py +++ b/redash/handlers/alerts.py @@ -82,7 +82,8 @@ class AlertSubscriptionListResource(BaseResource): '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()