Add: query snippets feature

This commit is contained in:
Arik Fraimovich 2016-08-22 23:54:20 +03:00
parent b8eca28e20
commit 10f5ecdb00
16 changed files with 298 additions and 6 deletions

View File

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

View File

@ -78,6 +78,7 @@
<script src="/scripts/controllers/query_view.js"></script>
<script src="/scripts/controllers/query_source.js"></script>
<script src="/scripts/controllers/users.js"></script>
<script src="/scripts/controllers/snippets.js"></script>
<script src="/scripts/visualizations/base.js"></script>
<script src="/scripts/visualizations/chart.js"></script>
<script src="/scripts/visualizations/cohort.js"></script>

View File

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

View File

@ -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": '<a href="query_snippets/{{dataRow.id}}">{{dataRow.trigger}}</a>'
},
{
"label": "Description",
"map": "description"
},
{
"label": "Snippet",
"map": "snippet"
},
{
'label': 'Created By',
'map': 'user.name'
},
{
'label': 'Updated At',
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
}
];
$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])
})();

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
<script src="/bower_components/ace-builds/src-min-noconflict/mode-sql.js"></script>
<script src="/bower_components/ace-builds/src-min-noconflict/mode-json.js"></script>
<script src="/bower_components/ace-builds/src-min-noconflict/mode-python.js"></script>
<script src="/bower_components/ace-builds/src-min-noconflict/mode-snippets.js"></script>
<script src="/bower_components/ace-builds/src-min-noconflict/ext-language_tools.js"></script>
<script src="/bower_components/angular/angular.js"></script>
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>

View File

@ -60,7 +60,7 @@
<a href="data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
</li>
<li ng-show="currentUser.hasPermission('list_users')">
<a href="users" title="Users"><i class="fa fa-users"></i></a>
<a href="users" title="Settings"><i class="fa fa-cog"></i></a>
</li>
<li class="dropdown" dropdown>
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span

View File

@ -8,6 +8,7 @@
<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>
<li ng-class="{'active': snippetsPage }"><a href="query_snippets">Query Snippets</a></li>
</ul>
<div ng-transclude>

View File

@ -0,0 +1,13 @@
<settings-screen>
<div class="row voffset1">
<div class="col-md-12">
<p>
<a href="query_snippets/new" class="btn btn-default"><i class="fa fa-plus"></i> New Snippet</a>
</p>
<smart-table rows="snippets" columns="gridColumns"
config="gridConfig"
class="table table-condensed table-hover"></smart-table>
</div>
</div>
</settings-screen>

View File

@ -0,0 +1,36 @@
<settings-screen>
<!--<h2 class="p-l-5">{{snippet.trigger}}</h2>-->
<div class="">
<!--<pre>-->
<!--{{snippet | json}}-->
<!--</pre>-->
<form name="snippetForm" class="form">
<div class="form-group">
<label>Trigger</label>
<input type="string" class="form-control" ng-model="snippet.trigger" ng-disabled="!canEdit" required>
</div>
<div class="form-group">
<label>Description</label>
<input type="string" class="form-control" ng-model="snippet.description" ng-disabled="!canEdit">
</div>
<div class="form-group">
<label>Snippet</label>
<pre ng-if="!canEdit">{{snippet.snippet}}</pre>
<div ui-ace="editorOptions" ng-model="snippet.snippet" style="height:300px" ng-if="canEdit"></div>
</div>
<div class="form-group" ng-if="canEdit">
<button class="btn btn-primary" ng-disabled="!snippetForm.$valid" ng-click="saveChanges()">Save</button>
<button class="btn btn-danger" ng-if="snippet.id" ng-click="delete()">Delete</button>
</div>
<small ng-if="snippet.user">
Created by: {{snippet.user.name}}
</small>
</form>
</div>
</settings-screen>

View File

@ -17,6 +17,7 @@ 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
from redash.handlers.query_snippets import QuerySnippetListResource, QuerySnippetResource
class ApiExt(Api):
@ -90,3 +91,6 @@ api.add_org_resource(WidgetResource, '/api/widgets/<int:widget_id>', endpoint='w
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')
api.add_org_resource(QuerySnippetResource, '/api/query_snippets/<snippet_id>', endpoint='query_snippet')
api.add_org_resource(QuerySnippetListResource, '/api/query_snippets', endpoint='query_snippets')

View File

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

View File

@ -81,6 +81,8 @@ rules = ['/admin/<anything>/<whatever>',
'/users/<pk>',
'/destinations',
'/destinations/<pk>',
'/query_snippets',
'/query_snippets/<pk>',
'/groups',
'/groups/<pk>',
'/groups/<pk>/data_sources',

View File

@ -1196,6 +1196,35 @@ class AlertSubscription(ModelTimestampsMixin, BaseModel):
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)