diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less index efb824d138..737a46dae1 100644 --- a/client/app/assets/less/redash/redash-newstyle.less +++ b/client/app/assets/less/redash/redash-newstyle.less @@ -181,7 +181,7 @@ body { cursor: pointer; } - .btn-favourite { + .btn-favourite, .btn-archive { font-size: 15px; } } @@ -194,7 +194,7 @@ body { } } -.btn-favourite { +.btn-favourite, .btn-archive { color: #d4d4d4; transition: all .25s ease-in-out; @@ -207,7 +207,20 @@ body { } } -.page-header--new .btn-favourite { +.btn-archive { + color: #d4d4d4; + transition: all .25s ease-in-out; + + &:hover, &:focus { + color: @gray-light; + } + + .fa-archive { + color: @gray-light; + } +} + +.page-header--new .btn-favourite, .page-header--new .btn-archive { font-size: 19px; } @@ -243,7 +256,7 @@ body { } } -.navbar li a .btn-favourite .fa { +.navbar li a .btn-favourite .fa, .navbar li a .btn-archive .fa { font-size: 100%; } diff --git a/client/app/pages/queries-list/index.js b/client/app/pages/queries-list/index.js index 0c120650f9..4b6eba7a97 100644 --- a/client/app/pages/queries-list/index.js +++ b/client/app/pages/queries-list/index.js @@ -32,6 +32,8 @@ class QueriesListCtrl extends ListCtrl { this.emptyType = 'favorites'; } else if (this.currentPage === 'my') { this.emptyType = 'my'; + } else if (this.currentPage === 'archive') { + this.emptyType = 'archive'; } else { this.emptyType = 'default'; } @@ -94,6 +96,20 @@ export default function init(ngModule) { }, route, ), + '/queries/archive': extend( + { + title: 'Archived Queries', + resolve: { + currentPage: () => 'archive', + resource: (Query) => { + 'ngInject'; + + return Query.archive.bind(Query); + }, + }, + }, + route, + ), // TODO: setup redirect? // '/queries/search': _.extend( }; diff --git a/client/app/pages/queries-list/queries-list.html b/client/app/pages/queries-list/queries-list.html index acc005bbff..6f681dd736 100644 --- a/client/app/pages/queries-list/queries-list.html +++ b/client/app/pages/queries-list/queries-list.html @@ -1,6 +1,10 @@
- - +
+
+
+
+
+
@@ -17,10 +21,19 @@ Favorites + + + + + + Archive + + My Queries +
@@ -51,6 +64,7 @@ query writing documentation.
+ @@ -127,10 +141,19 @@ Favorites + + + + + + Archive + + My Queries +
diff --git a/client/app/services/query.js b/client/app/services/query.js index 57ece987dd..71d0dfacc5 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -334,6 +334,11 @@ function QueryResource( isArray: true, url: 'api/queries/recent', }, + archive: { + method: 'get', + isArray: false, + url: 'api/queries/archive', + }, query: { isArray: false, }, diff --git a/redash/handlers/api.py b/redash/handlers/api.py index 140c37ce83..1fef2f5409 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -9,7 +9,7 @@ from redash.handlers.dashboards import DashboardListResource, DashboardResource, DashboardShareResource, PublicDashboardResource from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource from redash.handlers.events import EventsResource -from redash.handlers.queries import QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource +from redash.handlers.queries import QueryArchiveResource, QueryForkResource, QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource from redash.handlers.query_results import QueryResultListResource, QueryResultResource, JobResource from redash.handlers.users import UserResource, UserListResource, UserInviteResource, UserResetPasswordResource, UserDisableResource, UserRegenerateApiKeyResource from redash.handlers.visualizations import VisualizationListResource @@ -79,6 +79,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(QueryArchiveResource, '/api/queries/archive', endpoint='queries_archive') 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') diff --git a/redash/handlers/organization.py b/redash/handlers/organization.py index 384f4018db..464943a065 100644 --- a/redash/handlers/organization.py +++ b/redash/handlers/organization.py @@ -13,7 +13,7 @@ def organization_status(org_slug=None): 'users': models.User.all(current_org).count(), 'alerts': models.Alert.all(group_ids=current_user.group_ids).count(), 'data_sources': models.DataSource.all(current_org, group_ids=current_user.group_ids).count(), - 'queries': models.Query.all_queries(current_user.group_ids, current_user.id, drafts=True).count(), + 'queries': models.Query.all_queries(current_user.group_ids, current_user.id, include_drafts=True).count(), 'dashboards': models.Dashboard.query.filter(models.Dashboard.org==current_org, models.Dashboard.is_archived==False).count(), } diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index 7f33a52843..bd43c69576 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -102,7 +102,76 @@ def get(self): return QuerySerializer(results, with_last_modified_by=False, with_user=False).serialize() -class QueryListResource(BaseResource): +class BaseQueryListResource(BaseResource): + + def get_queries(self, search_term): + if search_term: + results = models.Query.search( + search_term, + self.current_user.group_ids, + self.current_user.id, + include_drafts=True, + ) + else: + results = models.Query.all_queries( + self.current_user.group_ids, + self.current_user.id, + include_drafts=True, + ) + return filter_by_tags(results, models.Query.tags) + + @require_permission('view_query') + def get(self): + """ + Retrieve a list of queries. + + :qparam number page_size: Number of queries to return per page + :qparam number page: Page number to retrieve + :qparam number order: Name of column to order by + :qparam number q: Full text search term + + Responds with an array of :ref:`query ` objects. + """ + # See if we want to do full-text search or just regular queries + search_term = request.args.get('q', '') + + queries = self.get_queries(search_term) + + results = filter_by_tags(queries, models.Query.tags) + + # order results according to passed order parameter, + # special-casing search queries where the database + # provides an order by search rank + ordered_results = order_results(results, fallback=bool(search_term)) + + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 25, type=int) + + response = paginate( + ordered_results, + page=page, + page_size=page_size, + serializer=QuerySerializer, + with_stats=True, + with_last_modified_by=False + ) + + if search_term: + self.record_event({ + 'action': 'search', + 'object_type': 'query', + 'term': search_term, + }) + else: + self.record_event({ + 'action': 'list', + 'object_type': 'query', + }) + + return response + + +class QueryListResource(BaseQueryListResource): @require_permission('create_query') def post(self): """ @@ -161,68 +230,26 @@ def post(self): return QuerySerializer(query).serialize() - @require_permission('view_query') - def get(self): - """ - Retrieve a list of queries. - - :qparam number page_size: Number of queries to return per page - :qparam number page: Page number to retrieve - :qparam number order: Name of column to order by - :qparam number q: Full text search term - Responds with an array of :ref:`query ` objects. - """ - # See if we want to do full-text search or just regular queries - search_term = request.args.get('q', '') +class QueryArchiveResource(BaseQueryListResource): + def get_queries(self, search_term): if search_term: - results = models.Query.search( + return models.Query.search( search_term, self.current_user.group_ids, self.current_user.id, - include_drafts=True, + include_drafts=False, + include_archived=True, ) else: - results = models.Query.all_queries( + return models.Query.all_queries( self.current_user.group_ids, self.current_user.id, - drafts=True, + include_drafts=False, + include_archived=True, ) - results = filter_by_tags(results, models.Query.tags) - - # order results according to passed order parameter, - # special-casing search queries where the database - # provides an order by search rank - ordered_results = order_results(results, fallback=bool(search_term)) - - page = request.args.get('page', 1, type=int) - page_size = request.args.get('page_size', 25, type=int) - - response = paginate( - ordered_results, - page=page, - page_size=page_size, - serializer=QuerySerializer, - with_stats=True, - with_last_modified_by=False - ) - - if search_term: - self.record_event({ - 'action': 'search', - 'object_type': 'query', - 'term': search_term, - }) - else: - self.record_event({ - 'action': 'list', - 'object_type': 'query', - }) - - return response - class MyQueriesResource(BaseResource): @require_permission('view_query') diff --git a/redash/models/__init__.py b/redash/models/__init__.py index 73ff622bd3..8f9f189181 100644 --- a/redash/models/__init__.py +++ b/redash/models/__init__.py @@ -466,7 +466,7 @@ def create(cls, **kwargs): return query @classmethod - def all_queries(cls, group_ids, user_id=None, drafts=False): + def all_queries(cls, group_ids, user_id=None, include_drafts=False, include_archived=False): query_ids = ( db.session .query(distinct(cls.id)) @@ -474,10 +474,10 @@ def all_queries(cls, group_ids, user_id=None, drafts=False): DataSourceGroup, Query.data_source_id == DataSourceGroup.data_source_id ) - .filter(Query.is_archived == False) + .filter(Query.is_archived.is_(include_archived)) .filter(DataSourceGroup.group_id.in_(group_ids)) ) - q = ( + queries = ( cls .query .options( @@ -503,19 +503,19 @@ def all_queries(cls, group_ids, user_id=None, drafts=False): .order_by(Query.created_at.desc()) ) - if not drafts: - q = q.filter( + if not include_drafts: + queries = queries.filter( or_( - Query.is_draft == False, + Query.is_draft.is_(False), Query.user_id == user_id ) ) - return q + return queries @classmethod def favorites(cls, user, base_query=None): if base_query is None: - base_query = cls.all_queries(user.group_ids, user.id, drafts=True) + base_query = cls.all_queries(user.group_ids, user.id, include_drafts=True) return base_query.join(( Favorite, and_( @@ -529,7 +529,7 @@ def all_tags(cls, user, include_drafts=False): queries = cls.all_queries( group_ids=user.group_ids, user_id=user.id, - drafts=include_drafts, + include_drafts=include_drafts, ) tag_column = func.unnest(cls.tags).label('tag') @@ -550,11 +550,13 @@ def by_user(cls, user): @classmethod def outdated_queries(cls): - queries = (Query.query - .options(joinedload(Query.latest_query_data).load_only('retrieved_at')) - .filter(Query.schedule.isnot(None)) - .order_by(Query.id)) - + queries = ( + Query.query + .options(joinedload(Query.latest_query_data).load_only('retrieved_at')) + .filter(Query.schedule.isnot(None)) + .order_by(Query.id) + ) + now = utils.utcnow() outdated_queries = {} scheduled_queries_executions.refresh() @@ -582,8 +584,14 @@ def outdated_queries(cls): return outdated_queries.values() @classmethod - def search(cls, term, group_ids, user_id=None, include_drafts=False, limit=None): - all_queries = cls.all_queries(group_ids, user_id=user_id, drafts=include_drafts) + def search(cls, term, group_ids, user_id=None, include_drafts=False, + limit=None, include_archived=False): + all_queries = cls.all_queries( + group_ids, + user_id=user_id, + include_drafts=include_drafts, + include_archived=include_archived, + ) # sort the result using the weight as defined in the search vector column return all_queries.search(term, sort=True).limit(limit) diff --git a/tests/handlers/test_queries.py b/tests/handlers/test_queries.py index d4219365e2..de587d6ed4 100644 --- a/tests/handlers/test_queries.py +++ b/tests/handlers/test_queries.py @@ -134,6 +134,7 @@ def test_works_for_non_owner_with_permission(self): self.assertEqual(rv.json['name'], 'Testing') self.assertEqual(rv.json['last_modified_by']['id'], user.id) + class TestQueryListResourceGet(BaseTestCase): def test_returns_queries(self): q1 = self.factory.create_query() @@ -147,8 +148,8 @@ def test_returns_queries(self): def test_filters_with_tags(self): q1 = self.factory.create_query(tags=[u'test']) - q2 = self.factory.create_query() - q3 = self.factory.create_query() + self.factory.create_query() + self.factory.create_query() rv = self.make_request('get', '/api/queries?tags=test') assert len(rv.json['results']) == 1 @@ -157,12 +158,13 @@ def test_filters_with_tags(self): def test_search_term(self): q1 = self.factory.create_query(name="Sales") q2 = self.factory.create_query(name="Q1 sales") - q3 = self.factory.create_query(name="Ops") + self.factory.create_query(name="Ops") rv = self.make_request('get', '/api/queries?q=sales') assert len(rv.json['results']) == 2 assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id]) + class TestQueryListResourcePost(BaseTestCase): def test_create_query(self): query_data = { @@ -185,6 +187,27 @@ def test_create_query(self): self.assertTrue(query.is_draft) +class TestQueryArchiveResourceGet(BaseTestCase): + def test_returns_queries(self): + q1 = self.factory.create_query(is_archived=True) + q2 = self.factory.create_query(is_archived=True) + self.factory.create_query() + + rv = self.make_request('get', '/api/queries/archive') + + assert len(rv.json['results']) == 2 + assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id]) + + def test_search_term(self): + q1 = self.factory.create_query(name="Sales", is_archived=True) + q2 = self.factory.create_query(name="Q1 sales", is_archived=True) + self.factory.create_query(name="Q2 sales") + + rv = self.make_request('get', '/api/queries/archive?q=sales') + assert len(rv.json['results']) == 2 + assert set(map(lambda d: d['id'], rv.json['results'])) == set([q1.id, q2.id]) + + class QueryRefreshTest(BaseTestCase): def setUp(self): super(QueryRefreshTest, self).setUp()