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 @@ +
+ + + + + + + + + + + + + + + + + + + + + +
NameCreated ByCreated AtRuntimeLast Executed AtUpdate 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):