diff --git a/migrations/0026_add_queries_org_index.py b/migrations/0026_add_queries_org_index.py
new file mode 100644
index 0000000000..259a1202e6
--- /dev/null
+++ b/migrations/0026_add_queries_org_index.py
@@ -0,0 +1,12 @@
+from redash.models import db
+from playhouse.migrate import PostgresqlMigrator, migrate
+
+if __name__ == '__main__':
+ migrator = PostgresqlMigrator(db.database)
+
+ with db.database.transaction():
+ migrate(
+ migrator.add_index('queries', ('org_id',)),
+ )
+
+ db.close_db(None)
diff --git a/rd_ui/app/scripts/app.js b/rd_ui/app/scripts/app.js
index ae4f96eeb0..9cf836e2e3 100644
--- a/rd_ui/app/scripts/app.js
+++ b/rd_ui/app/scripts/app.js
@@ -73,6 +73,16 @@ angular.module('redash', [
}]
}
});
+ $routeProvider.when('/queries/my', {
+ templateUrl: '/views/queries.html',
+ controller: 'QueriesCtrl',
+ reloadOnSearch: false
+ });
+ $routeProvider.when('/queries/drafts', {
+ templateUrl: '/views/queries.html',
+ controller: 'QueriesCtrl',
+ reloadOnSearch: false
+ });
$routeProvider.when('/queries/search', {
templateUrl: '/views/queries_search_results.html',
controller: 'QuerySearchCtrl',
diff --git a/rd_ui/app/scripts/controllers/controllers.js b/rd_ui/app/scripts/controllers/controllers.js
index 46f3323e41..61420be972 100644
--- a/rd_ui/app/scripts/controllers/controllers.js
+++ b/rd_ui/app/scripts/controllers/controllers.js
@@ -63,91 +63,66 @@
};
var QueriesCtrl = function ($scope, $http, $location, $filter, Query) {
- $scope.$parent.pageTitle = "All Queries";
- $scope.gridConfig = {
- isPaginationEnabled: true,
- itemsByPage: 50,
- maxSize: 8,
- isGlobalSearchActivated: true};
+ var loader;
- $scope.allQueries = [];
$scope.queries = [];
+ $scope.page = parseInt($location.search().page || 1);
+ $scope.total = undefined;
+ $scope.pageSize = 25;
+
+ function loadQueries(resource, defaultOptions) {
+ return function(options) {
+ options = _.extend({}, defaultOptions, options);
+ resource(options, function (queries) {
+ $scope.totalQueriesCount = queries.count;
+ $scope.queries = _.map(queries.results, function (query) {
+ query.created_at = moment(query.created_at);
+ query.retrieved_at = moment(query.retrieved_at);
+ return query;
+ });
+ });
+ }
+ }
- var filterQueries = function () {
- $scope.queries = _.filter($scope.allQueries, function (query) {
- if (!$scope.selectedTab) {
- return false;
- }
-
- if ($scope.selectedTab.key == 'my') {
- return query.user.id == currentUser.id && query.name != 'New Query';
- } else if ($scope.selectedTab.key == 'drafts') {
- return query.user.id == currentUser.id && query.name == 'New Query';
- }
-
- return query.name != 'New Query';
- });
+ switch($location.path()) {
+ case '/queries':
+ $scope.$parent.pageTitle = "Queries";
+ // page title
+ loader = loadQueries(Query.query);
+ break;
+ case '/queries/drafts':
+ $scope.$parent.pageTitle = "Drafts";
+ loader = loadQueries(Query.myQueries, {drafts: true});
+ break;
+ case '/queries/my':
+ $scope.$parent.pageTitle = "My Queries";
+ loader = loadQueries(Query.myQueries);
+ break;
}
- Query.query(function (queries) {
- $scope.allQueries = _.map(queries, function (query) {
- query.created_at = moment(query.created_at);
- query.retrieved_at = moment(query.retrieved_at);
- return query;
- });
+ var loadAllQueries = loadQueries(Query.query);
+ var loadMyQueries = loadQueries(Query.myQueries);
- filterQueries();
- });
+ function load() {
+ var options = {page: $scope.page, page_size: $scope.pageSize};
+ loader(options);
+ }
- $scope.gridColumns = [
- {
- "label": "Name",
- "map": "name",
- "cellTemplateUrl": "/views/queries_query_name_cell.html"
- },
- {
- 'label': 'Created By',
- 'map': 'user.name'
- },
- {
- 'label': 'Created At',
- 'map': 'created_at',
- 'formatFunction': dateFormatter
- },
- {
- 'label': 'Runtime',
- 'map': 'runtime',
- 'formatFunction': function (value) {
- return $filter('durationHumanize')(value);
- }
- },
- {
- 'label': 'Last Executed At',
- 'map': 'retrieved_at',
- 'formatFunction': dateFormatter
- },
- {
- 'label': 'Update Schedule',
- 'map': 'schedule',
- 'formatFunction': function (value) {
- return $filter('scheduleHumanize')(value);
- }
- }
- ]
+ $scope.selectPage = function(page) {
+ $location.search('page', page);
+ $scope.page = page;
+ load();
+ }
$scope.tabs = [
- {"name": "My Queries", "key": "my"},
- {"key": "all", "name": "All Queries"},
- {"key": "drafts", "name": "Drafts"}
+ {"name": "My Queries", "path": "queries/my", loader: loadMyQueries},
+ {"path": "queries", "name": "All Queries", isActive: function(path) {
+ return path === '/queries';
+ }, "loader": loadAllQueries},
+ {"path": "queries/drafts", "name": "Drafts", loader: loadMyQueries},
];
- $scope.$watch('selectedTab', function (tab) {
- if (tab) {
- $scope.$parent.pageTitle = tab.name;
- }
-
- filterQueries();
- });
+ load();
}
var MainCtrl = function ($scope, $location, Dashboard) {
diff --git a/rd_ui/app/scripts/directives/directives.js b/rd_ui/app/scripts/directives/directives.js
index 9c0d7148c9..2144106366 100644
--- a/rd_ui/app/scripts/directives/directives.js
+++ b/rd_ui/app/scripts/directives/directives.js
@@ -483,21 +483,121 @@
restrict: 'E',
transclude: true,
templateUrl: '/views/directives/settings_screen.html',
- link: function(scope, elem, attrs) {
- 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.snippetsPage = _.string.startsWith($location.path(), '/query_snippets');
-
- scope.showGroupsLink = currentUser.hasPermission('list_users');
- scope.showUsersLink = currentUser.hasPermission('list_users');
- scope.showDsLink = currentUser.hasPermission('admin');
- scope.showDestinationsLink = currentUser.hasPermission('admin');
+ controller: ['$scope', function(scope) {
+ scope.tabs = [];
+
+ if (currentUser.hasPermission('admin')) {
+ scope.tabs.push({name: 'Data Sources', path: 'data_sources'});
+ }
+
+ if (currentUser.hasPermission('list_users')) {
+ scope.tabs.push({name: 'Users', path: 'users'});
+ scope.tabs.push({name: 'Groups', path: 'groups'});
+ }
+
+ if (currentUser.hasPermission('admin')) {
+ scope.tabs.push({name: 'Alert Destinations', path: 'destinations'});
+ }
+
+ scope.tabs.push({name: "Query Snippets", path: "query_snippets"});
+ }]
+ }
+ }]);
+
+ directives.directive('tabNav', ['$location', function($location) {
+ return {
+ restrict: 'E',
+ transclude: true,
+ scope: {
+ tabs: '='
+ },
+ template: '
',
+ link: function($scope) {
+ _.each($scope.tabs, function(tab) {
+ if (tab.isActive) {
+ tab.active = tab.isActive($location.path());
+ } else {
+ tab.active = _.string.startsWith($location.path(), "/" + tab.path);
+ }
+ });
+ }
+ }
+ }]);
+
+ directives.directive('queriesList', [function () {
+ return {
+ restrict: 'E',
+ replace: true,
+ scope: {
+ queries: '=',
+ total: '=',
+ selectPage: '=',
+ page: '=',
+ pageSize: '='
+ },
+ templateUrl: '/views/directives/queries_list.html',
+ link: function ($scope) {
+ function hasNext() {
+ return !($scope.page * $scope.pageSize >= $scope.total);
+ }
+
+ function hasPrevious() {
+ return $scope.page !== 1;
+ }
+
+ function updatePages() {
+ if ($scope.total === undefined) {
+ return;
+ }
+
+ var maxSize = 5;
+ var pageCount = Math.ceil($scope.total/$scope.pageSize);
+ var pages = [];
+
+ function makePage(title, page, disabled) {
+ return {title: title, page: page, active: page == $scope.page, disabled: disabled};
+ }
+
+ // Default page limits
+ var startPage = 1, endPage = pageCount;
+
+ // recompute if maxSize
+ if (maxSize && maxSize < pageCount) {
+ startPage = Math.max($scope.page - Math.floor(maxSize / 2), 1);
+ endPage = startPage + maxSize - 1;
+
+ // Adjust if limit is exceeded
+ if (endPage > pageCount) {
+ endPage = pageCount;
+ startPage = endPage - maxSize + 1;
+ }
+ }
+
+ // Add page number links
+ for (var number = startPage; number <= endPage; number++) {
+ var page = makePage(number, number, false);
+ pages.push(page);
+ }
+
+ // Add previous & next links
+ var previousPage = makePage('<', $scope.page - 1, !hasPrevious());
+ pages.unshift(previousPage);
+
+ var nextPage = makePage('>', $scope.page + 1, !hasNext());
+ pages.push(nextPage);
+
+ $scope.pages = pages;
+ }
+
+ $scope.$watch('total', updatePages);
+ $scope.$watch('page', updatePages);
}
}
}]);
+
directives.directive('parameters', ['$location', '$modal', function($location, $modal) {
return {
restrict: 'E',
diff --git a/rd_ui/app/scripts/filters.js b/rd_ui/app/scripts/filters.js
index 6a42fa594f..7530b59f55 100644
--- a/rd_ui/app/scripts/filters.js
+++ b/rd_ui/app/scripts/filters.js
@@ -71,6 +71,9 @@ angular.module('redash.filters', []).
.filter('dateTime', function() {
return function(value) {
+ if (!value) {
+ return '-';
+ }
return moment(value).format(clientConfig.dateTimeFormat);
}
})
diff --git a/rd_ui/app/scripts/services/resources.js b/rd_ui/app/scripts/services/resources.js
index 6b12993d33..03beb0b34f 100644
--- a/rd_ui/app/scripts/services/resources.js
+++ b/rd_ui/app/scripts/services/resources.js
@@ -446,6 +446,14 @@
method: 'get',
isArray: true,
url: "api/queries/recent"
+ },
+ query: {
+ isArray: false
+ },
+ myQueries: {
+ method: 'get',
+ isArray: false,
+ url: "api/queries/my"
}
});
@@ -769,7 +777,7 @@
"tabTrigger": this.trigger
};
}
-
+
return resource;
};
diff --git a/rd_ui/app/views/directives/queries_list.html b/rd_ui/app/views/directives/queries_list.html
new file mode 100644
index 0000000000..9efb61ea8b
--- /dev/null
+++ b/rd_ui/app/views/directives/queries_list.html
@@ -0,0 +1,30 @@
+
+
+
+
+ Name |
+ Created By |
+ Created At |
+ Runtime |
+ Last Executed At |
+ Update Schedule |
+
+
+
+
+ {{query.name}} |
+ {{query.user.name}} |
+ {{query.created_at | dateTime}} |
+ {{query.runtime | durationHumanize}} |
+ {{query.retrieved_at | dateTime}} |
+ {{query.schedule | scheduleHumanize}} |
+
+
+
+
+
+
+
+
diff --git a/rd_ui/app/views/directives/settings_screen.html b/rd_ui/app/views/directives/settings_screen.html
index e8f1c9ca30..faef3462eb 100644
--- a/rd_ui/app/views/directives/settings_screen.html
+++ b/rd_ui/app/views/directives/settings_screen.html
@@ -3,13 +3,7 @@
-
+
diff --git a/rd_ui/app/views/queries.html b/rd_ui/app/views/queries.html
index cfa78c2e23..7bb2ff13fc 100644
--- a/rd_ui/app/views/queries.html
+++ b/rd_ui/app/views/queries.html
@@ -1,8 +1,6 @@
diff --git a/redash/handlers/api.py b/redash/handlers/api.py
index 5372e69b89..cc8da078c9 100644
--- a/redash/handlers/api.py
+++ b/redash/handlers/api.py
@@ -8,7 +8,7 @@
from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource
from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource
from redash.handlers.events import EventResource
-from redash.handlers.queries import QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource
+from redash.handlers.queries import QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource
from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource
from redash.handlers.users import UserResource, UserListResource, UserInviteResource, UserResetPasswordResource
from redash.handlers.visualizations import VisualizationListResource
@@ -66,6 +66,7 @@ def json_representation(data, code, headers=None):
api.add_org_resource(QuerySearchResource, '/api/queries/search', endpoint='queries_search')
api.add_org_resource(QueryRecentResource, '/api/queries/recent', endpoint='recent_queries')
api.add_org_resource(QueryListResource, '/api/queries', endpoint='queries')
+api.add_org_resource(MyQueriesResource, '/api/queries/my', endpoint='my_queries')
api.add_org_resource(QueryRefreshResource, '/api/queries/
/refresh', endpoint='query_refresh')
api.add_org_resource(QueryResource, '/api/queries/', endpoint='query')
diff --git a/redash/handlers/base.py b/redash/handlers/base.py
index 882734b77a..2d963ff773 100644
--- a/redash/handlers/base.py
+++ b/redash/handlers/base.py
@@ -72,6 +72,28 @@ def get_object_or_404(fn, *args, **kwargs):
abort(404)
+def paginate(query_set, page, page_size, serializer):
+ count = query_set.count()
+
+ if page < 1:
+ abort(400, message='Page must be positive integer.')
+
+ if (page-1)*page_size+1 > count > 0:
+ abort(400, message='Page is out of range.')
+
+ if page_size > 250 or page_size < 1:
+ abort(400, message='Page size is out of range (1-250).')
+
+ results = query_set.paginate(page, page_size)
+
+ return {
+ 'count': count,
+ 'page': page,
+ 'page_size': page_size,
+ 'results': [serializer(result) for result in results],
+ }
+
+
def org_scoped_rule(rule):
if settings.MULTI_ORG:
return "/{}".format(rule)
diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py
index 91230a85b2..d4d9c18bd7 100644
--- a/redash/handlers/queries.py
+++ b/redash/handlers/queries.py
@@ -6,7 +6,7 @@
from funcy import distinct, take
from itertools import chain
-from redash.handlers.base import routes, org_scoped_rule
+from redash.handlers.base import routes, org_scoped_rule, paginate
from redash.handlers.query_results import run_query
from redash import models
from redash.permissions import require_permission, require_access, require_admin_or_owner, not_view_only, view_only
@@ -73,7 +73,20 @@ def post(self):
@require_permission('view_query')
def get(self):
- return [q.to_dict(with_stats=True) for q in models.Query.all_queries(self.current_user.groups)]
+ results = models.Query.all_queries(self.current_user.groups)
+ page = request.args.get('page', 1, type=int)
+ page_size = request.args.get('page_size', 25, type=int)
+ return paginate(results, page, page_size, lambda q: q.to_dict(with_stats=True))
+
+
+class MyQueriesResource(BaseResource):
+ @require_permission('view_query')
+ def get(self):
+ drafts = request.args.get('drafts') is not None
+ results = models.Query.by_user(self.current_user, drafts)
+ page = request.args.get('page', 1, type=int)
+ page_size = request.args.get('page_size', 25, type=int)
+ return paginate(results, page, page_size, lambda q: q.to_dict(with_stats=True))
class QueryResource(BaseResource):
diff --git a/redash/models.py b/redash/models.py
index c9ca984b9a..f794e50142 100644
--- a/redash/models.py
+++ b/redash/models.py
@@ -579,7 +579,7 @@ def should_schedule_next(previous_iteration, now, schedule):
class Query(ModelTimestampsMixin, BaseModel, BelongsToOrgMixin):
id = peewee.PrimaryKeyField()
- org = peewee.ForeignKeyField(Organization, related_name="queries")
+ org = peewee.ForeignKeyField(Organization, related_name="queries", index=True)
data_source = peewee.ForeignKeyField(DataSource, null=True)
latest_query_data = peewee.ForeignKeyField(QueryResult, null=True)
name = peewee.CharField(max_length=255)
@@ -647,7 +647,7 @@ def archive(self):
self.save()
@classmethod
- def all_queries(cls, groups):
+ def all_queries(cls, groups, drafts=False):
q = Query.select(Query, User, QueryResult.retrieved_at, QueryResult.runtime)\
.join(QueryResult, join_type=peewee.JOIN_LEFT_OUTER)\
.switch(Query).join(User)\
@@ -657,8 +657,17 @@ def all_queries(cls, groups):
.group_by(Query.id, User.id, QueryResult.id, QueryResult.retrieved_at, QueryResult.runtime)\
.order_by(cls.created_at.desc())
+ if drafts:
+ q = q.where(Query.name == 'New Query')
+ else:
+ q = q.where(Query.name != 'New Query')
+
return q
+ @classmethod
+ def by_user(cls, user, drafts):
+ return cls.all_queries(user.groups, drafts).where(Query.user==user)
+
@classmethod
def outdated_queries(cls):
queries = cls.select(cls, QueryResult.retrieved_at, DataSource)\
diff --git a/tests/factories.py b/tests/factories.py
index b6d7cf3055..8b39dc8e9a 100644
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -63,7 +63,7 @@ def __call__(self):
object=dashboard_factory.create)
query_factory = ModelFactory(redash.models.Query,
- name='New Query',
+ name='Query',
description='',
query='SELECT 1',
user=user_factory.create,
diff --git a/tests/handlers/test_paginate.py b/tests/handlers/test_paginate.py
new file mode 100644
index 0000000000..522d0567fe
--- /dev/null
+++ b/tests/handlers/test_paginate.py
@@ -0,0 +1,30 @@
+from werkzeug.exceptions import BadRequest
+
+from redash.handlers.base import paginate
+from unittest import TestCase
+from mock import MagicMock
+
+dummy_results = [i for i in range(25)]
+
+
+class TestPaginate(TestCase):
+ def setUp(self):
+ self.query_set = MagicMock()
+ self.query_set.count = MagicMock(return_value=102)
+ self.query_set.paginate = MagicMock(return_value=dummy_results)
+
+ def test_returns_paginated_results(self):
+ page = paginate(self.query_set, 1, 25, lambda x: x)
+ self.assertEqual(page['page'], 1)
+ self.assertEqual(page['page_size'], 25)
+ self.assertEqual(page['count'], 102)
+ self.assertEqual(page['results'], dummy_results)
+
+ def test_raises_error_for_bad_page(self):
+ self.assertRaises(BadRequest, lambda: paginate(self.query_set, -1, 25, lambda x: x))
+ self.assertRaises(BadRequest, lambda: paginate(self.query_set, 6, 25, lambda x: x))
+
+ def test_raises_error_for_bad_page_size(self):
+ self.assertRaises(BadRequest, lambda: paginate(self.query_set, 1, 251, lambda x: x))
+ self.assertRaises(BadRequest, lambda: paginate(self.query_set, 1, -1, lambda x: x))
+
diff --git a/tests/handlers/test_queries.py b/tests/handlers/test_queries.py
index bd0baee4f1..54923ac210 100644
--- a/tests/handlers/test_queries.py
+++ b/tests/handlers/test_queries.py
@@ -50,7 +50,7 @@ def test_get_all_queries(self):
rv = self.make_request('get', '/api/queries')
self.assertEquals(rv.status_code, 200)
- self.assertEquals(len(rv.json), 10)
+ self.assertEquals(len(rv.json['results']), 10)
def test_query_without_data_source_should_be_available_only_by_admin(self):
query = self.factory.create_query()
diff --git a/tests/test_models.py b/tests/test_models.py
index 3304613391..a3814924b8 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -251,12 +251,12 @@ def test_archived_query_doesnt_return_in_all(self):
query.latest_query_data = query_result
query.save()
- self.assertIn(query, models.Query.all_queries(query.groups.keys()))
+ self.assertIn(query, list(models.Query.all_queries(query.groups.keys())))
self.assertIn(query, models.Query.outdated_queries())
query.archive()
- self.assertNotIn(query, models.Query.all_queries(query.groups.keys()))
+ self.assertNotIn(query, list(models.Query.all_queries(query.groups.keys())))
self.assertNotIn(query, models.Query.outdated_queries())
def test_removes_associated_widgets_from_dashboards(self):
@@ -418,10 +418,10 @@ def test_returns_only_queries_in_given_groups(self):
q1 = self.factory.create_query(data_source=ds1)
q2 = self.factory.create_query(data_source=ds2)
- self.assertIn(q1, models.Query.all_queries([group1]))
- self.assertNotIn(q2, models.Query.all_queries([group1]))
- self.assertIn(q1, models.Query.all_queries([group1, group2]))
- self.assertIn(q2, models.Query.all_queries([group1, group2]))
+ self.assertIn(q1, list(models.Query.all_queries([group1])))
+ self.assertNotIn(q2, list(models.Query.all_queries([group1])))
+ self.assertIn(q1, list(models.Query.all_queries([group1, group2])))
+ self.assertIn(q2, list(models.Query.all_queries([group1, group2])))
class TestUser(BaseTestCase):