diff --git a/migrations/0025_add_notification_destination.py b/migrations/0025_add_notification_destination.py new file mode 100644 index 0000000000..4d46f6f5e0 --- /dev/null +++ b/migrations/0025_add_notification_destination.py @@ -0,0 +1,8 @@ +from redash.models import db, QuerySnippet + +if __name__ == '__main__': + with db.database.transaction(): + if not QuerySnippet.table_exists(): + QuerySnippet.create_table() + + db.close_db(None) diff --git a/rd_ui/app/app_layout.html b/rd_ui/app/app_layout.html index 70e1c7e1cc..2b5e142965 100644 --- a/rd_ui/app/app_layout.html +++ b/rd_ui/app/app_layout.html @@ -78,6 +78,7 @@

+ diff --git a/rd_ui/app/scripts/app.js b/rd_ui/app/scripts/app.js index f61385c9ad..ae4f96eeb0 100644 --- a/rd_ui/app/scripts/app.js +++ b/rd_ui/app/scripts/app.js @@ -150,7 +150,15 @@ angular.module('redash', [ $routeProvider.when('/groups', { templateUrl: '/views/groups/list.html', controller: 'GroupsCtrl' - }) + }); + $routeProvider.when('/query_snippets/:snippetId', { + templateUrl: '/views/query_snippets/show.html', + controller: 'SnippetCtrl' + }); + $routeProvider.when('/query_snippets', { + templateUrl: '/views/query_snippets/list.html', + controller: 'SnippetsCtrl' + }); $routeProvider.when('/', { templateUrl: '/views/index.html', controller: 'IndexCtrl' diff --git a/rd_ui/app/scripts/controllers/snippets.js b/rd_ui/app/scripts/controllers/snippets.js new file mode 100644 index 0000000000..e146e9bef2 --- /dev/null +++ b/rd_ui/app/scripts/controllers/snippets.js @@ -0,0 +1,93 @@ +(function() { + var SnippetsCtrl = function ($scope, $location, growl, Events, QuerySnippet) { + Events.record(currentUser, "view", "page", "query_snippets"); + $scope.$parent.pageTitle = "Query Snippets"; + + $scope.gridConfig = { + isPaginationEnabled: true, + itemsByPage: 20, + maxSize: 8, + }; + + $scope.gridColumns = [ + { + "label": "Trigger", + "cellTemplate": '{{dataRow.trigger}}' + }, + { + "label": "Description", + "map": "description" + }, + { + "label": "Snippet", + "map": "snippet" + }, + { + 'label': 'Created By', + 'map': 'user.name' + }, + { + 'label': 'Updated At', + 'cellTemplate': '' + } + ]; + + $scope.snippets = []; + QuerySnippet.query(function(snippets) { + $scope.snippets = snippets; + }); + }; + + var SnippetCtrl = function ($scope, $routeParams, $http, $location, growl, Events, QuerySnippet) { + $scope.$parent.pageTitle = "Query Snippets"; + $scope.snippetId = $routeParams.snippetId; + Events.record(currentUser, "view", "query_snippet", $scope.snippetId); + + $scope.editorOptions = { + mode: 'snippets', + advanced: { + behavioursEnabled: true, + enableSnippets: false, + autoScrollEditorIntoView: true, + }, + onLoad: function(editor) { + editor.$blockScrolling = Infinity; + editor.getSession().setUseWrapMode(true); + editor.setShowPrintMargin(false); + } + }; + + $scope.saveChanges = function() { + $scope.snippet.$save(function(snippet) { + growl.addSuccessMessage("Saved."); + if ($scope.snippetId === "new") { + $location.path('/query_snippets/' + snippet.id).replace(); + } + }, function() { + growl.addErrorMessage("Failed saving snippet."); + }); + } + + $scope.delete = function() { + $scope.snippet.$delete(function() { + $location.path('/query_snippets'); + growl.addSuccessMessage("Query snippet deleted."); + }, function() { + growl.addErrorMessage("Failed deleting query snippet."); + }); + } + + if ($scope.snippetId == 'new') { + $scope.snippet = new QuerySnippet({description: ""}); + $scope.canEdit = true; + } else { + $scope.snippet = QuerySnippet.get({id: $scope.snippetId}, function(snippet) { + $scope.canEdit = currentUser.canEdit(snippet); + }); + } + }; + + angular.module('redash.controllers') + .controller('SnippetsCtrl', ['$scope', '$location', 'growl', 'Events', 'QuerySnippet', SnippetsCtrl]) + .controller('SnippetCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'QuerySnippet', SnippetCtrl]) +})(); diff --git a/rd_ui/app/scripts/directives/directives.js b/rd_ui/app/scripts/directives/directives.js index 7dd331b4cb..9c0d7148c9 100644 --- a/rd_ui/app/scripts/directives/directives.js +++ b/rd_ui/app/scripts/directives/directives.js @@ -488,6 +488,7 @@ scope.groupsPage = _.string.startsWith($location.path(), '/groups'); scope.dsPage = _.string.startsWith($location.path(), '/data_sources'); scope.destinationsPage = _.string.startsWith($location.path(), '/destinations'); + scope.snippetsPage = _.string.startsWith($location.path(), '/query_snippets'); scope.showGroupsLink = currentUser.hasPermission('list_users'); scope.showUsersLink = currentUser.hasPermission('list_users'); diff --git a/rd_ui/app/scripts/directives/query_directives.js b/rd_ui/app/scripts/directives/query_directives.js index d272ad3c5c..b05814ca8d 100644 --- a/rd_ui/app/scripts/directives/query_directives.js +++ b/rd_ui/app/scripts/directives/query_directives.js @@ -75,7 +75,7 @@ defineDummySnippets("sql"); defineDummySnippets("json"); - function queryEditor() { + function queryEditor(QuerySnippet) { return { restrict: 'E', scope: { @@ -100,7 +100,19 @@ autoScrollEditorIntoView: true, }, onLoad: function(editor) { - // Test for snippet manager + QuerySnippet.query(function(snippets) { + var snippetManager = ace.require("ace/snippets").snippetManager; + var m = { + snippetText: '' + }; + m.snippets = snippetManager.parseSnippetFile(m.snippetText); + _.each(snippets, function(snippet) { + m.snippets.push(snippet.getSnippet()); + }); + + snippetManager.register(m.snippets || [], m.scope); + }); + editor.$blockScrolling = Infinity; editor.getSession().setUseWrapMode(true); editor.setShowPrintMargin(false); @@ -314,7 +326,7 @@ .directive('queryLink', queryLink) .directive('querySourceLink', ['$location', querySourceLink]) .directive('queryResultLink', queryResultLink) - .directive('queryEditor', queryEditor) + .directive('queryEditor', ['QuerySnippet', queryEditor]) .directive('queryRefreshSelect', queryRefreshSelect) .directive('queryTimePicker', queryTimePicker) .directive('queryFormatter', ['$http', 'growl', queryFormatter]); diff --git a/rd_ui/app/scripts/services/resources.js b/rd_ui/app/scripts/services/resources.js index 339849d136..6b12993d33 100644 --- a/rd_ui/app/scripts/services/resources.js +++ b/rd_ui/app/scripts/services/resources.js @@ -755,6 +755,24 @@ return resource; }; + var QuerySnippet = function ($resource) { + var resource = $resource('api/query_snippets/:id', {id: '@id'}); + resource.prototype.getSnippet = function() { + var name = this.trigger; + if (this.description !== "") { + name = this.trigger + ": " + this.description; + } + + return { + "name": name, + "content": this.snippet, + "tabTrigger": this.trigger + }; + } + + return resource; + }; + var Widget = function ($resource, Query) { var WidgetResource = $resource('api/widgets/:id', {id: '@id'}); @@ -785,5 +803,6 @@ .factory('AlertSubscription', ['$resource', AlertSubscription]) .factory('Widget', ['$resource', 'Query', Widget]) .factory('User', ['$resource', '$http', User]) - .factory('Group', ['$resource', Group]); + .factory('Group', ['$resource', Group]) + .factory('QuerySnippet', ['$resource', QuerySnippet]); })(); diff --git a/rd_ui/app/vendor_scripts.html b/rd_ui/app/vendor_scripts.html index 54b5fc98f5..70b9f242dc 100644 --- a/rd_ui/app/vendor_scripts.html +++ b/rd_ui/app/vendor_scripts.html @@ -4,6 +4,7 @@ + diff --git a/rd_ui/app/views/app_header.html b/rd_ui/app/views/app_header.html index 7648dd5b44..75c335295d 100644 --- a/rd_ui/app/views/app_header.html +++ b/rd_ui/app/views/app_header.html @@ -60,7 +60,7 @@
  • - +
  • Groups
  • Alert Destinations
  • +
  • Query Snippets
  • diff --git a/rd_ui/app/views/query_snippets/list.html b/rd_ui/app/views/query_snippets/list.html new file mode 100644 index 0000000000..e0e7b5ac9f --- /dev/null +++ b/rd_ui/app/views/query_snippets/list.html @@ -0,0 +1,13 @@ + +
    +
    +

    + New Snippet +

    + + +
    +
    +
    diff --git a/rd_ui/app/views/query_snippets/show.html b/rd_ui/app/views/query_snippets/show.html new file mode 100644 index 0000000000..2dfbf9c43d --- /dev/null +++ b/rd_ui/app/views/query_snippets/show.html @@ -0,0 +1,36 @@ + + + +
    + + + + +
    +
    + + +
    + +
    + + +
    + +
    + +
    {{snippet.snippet}}
    +
    +
    + +
    + + +
    + + Created by: {{snippet.user.name}} + +
    + +
    +
    diff --git a/redash/handlers/api.py b/redash/handlers/api.py index 16fa2e59b2..5372e69b89 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -17,6 +17,7 @@ from redash.handlers.groups import GroupListResource, GroupResource, GroupMemberListResource, GroupMemberResource, \ GroupDataSourceListResource, GroupDataSourceResource from redash.handlers.destinations import DestinationTypeListResource, DestinationResource, DestinationListResource +from redash.handlers.query_snippets import QuerySnippetListResource, QuerySnippetResource class ApiExt(Api): @@ -90,3 +91,6 @@ def json_representation(data, code, headers=None): 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') + +api.add_org_resource(QuerySnippetResource, '/api/query_snippets/', endpoint='query_snippet') +api.add_org_resource(QuerySnippetListResource, '/api/query_snippets', endpoint='query_snippets') diff --git a/redash/handlers/query_snippets.py b/redash/handlers/query_snippets.py new file mode 100644 index 0000000000..7614bce685 --- /dev/null +++ b/redash/handlers/query_snippets.py @@ -0,0 +1,64 @@ +from flask import request +from funcy import project + +from redash import models +from redash.permissions import require_admin_or_owner +from redash.handlers.base import BaseResource, require_fields, get_object_or_404 + + +class QuerySnippetResource(BaseResource): + def get(self, snippet_id): + snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org) + return snippet.to_dict() + + def post(self, snippet_id): + req = request.get_json(True) + params = project(req, ('trigger', 'description', 'snippet')) + snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org) + require_admin_or_owner(snippet.user.id) + + snippet.update_instance(**params) + + self.record_event({ + 'action': 'edit', + 'object_id': snippet.id, + 'object_type': 'query_snippet' + }) + + return snippet.to_dict() + + def delete(self, snippet_id): + snippet = get_object_or_404(models.QuerySnippet.get_by_id_and_org, snippet_id, self.current_org) + require_admin_or_owner(snippet.user.id) + snippet.delete_instance() + + self.record_event({ + 'action': 'delete', + 'object_id': snippet.id, + 'object_type': 'query_snippet' + }) + + +class QuerySnippetListResource(BaseResource): + def post(self): + req = request.get_json(True) + require_fields(req, ('trigger', 'description', 'snippet')) + + snippet = models.QuerySnippet.create( + trigger=req['trigger'], + description=req['description'], + snippet=req['snippet'], + user=self.current_user, + org=self.current_org + ) + + self.record_event({ + 'action': 'create', + 'object_id': snippet.id, + 'object_type': 'query_snippet' + }) + + return snippet.to_dict() + + def get(self): + return [snippet.to_dict() for snippet in models.QuerySnippet.all(org=self.current_org)] diff --git a/redash/handlers/static.py b/redash/handlers/static.py index 7fc1d6ad64..7706b27337 100644 --- a/redash/handlers/static.py +++ b/redash/handlers/static.py @@ -81,6 +81,8 @@ def register_static_routes(rules): '/users/', '/destinations', '/destinations/', + '/query_snippets', + '/query_snippets/', '/groups', '/groups/', '/groups//data_sources', diff --git a/redash/models.py b/redash/models.py index 68ecc3e78c..c9ca984b9a 100644 --- a/redash/models.py +++ b/redash/models.py @@ -1196,6 +1196,35 @@ def notify(self, alert, query, user, new_state, app, host): return destination.notify(alert, query, user, new_state, app, host, options) +class QuerySnippet(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin): + id = peewee.PrimaryKeyField() + org = peewee.ForeignKeyField(Organization, related_name="query_snippets") + trigger = peewee.CharField(unique=True) + description = peewee.TextField() + user = peewee.ForeignKeyField(User, related_name="query_snippets") + snippet = peewee.TextField() + + class Meta: + db_table = 'query_snippets' + + @classmethod + def all(cls, org): + return cls.select().where(cls.org==org) + + def to_dict(self): + d = { + 'id': self.id, + 'trigger': self.trigger, + 'description': self.description, + 'snippet': self.snippet, + 'user': self.user.to_dict(), + 'updated_at': self.updated_at, + 'created_at': self.created_at + } + + return d + + all_models = (Organization, Group, DataSource, DataSourceGroup, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, Event, NotificationDestination, AlertSubscription, ApiKey)