diff --git a/rd_ui/app/scripts/controllers/query_source.js b/rd_ui/app/scripts/controllers/query_source.js index d005363f2..30fc90dbc 100644 --- a/rd_ui/app/scripts/controllers/query_source.js +++ b/rd_ui/app/scripts/controllers/query_source.js @@ -14,7 +14,8 @@ var isNewQuery = !$scope.query.id, queryText = $scope.query.query, // ref to QueryViewCtrl.saveQuery - saveQuery = $scope.saveQuery; + saveQuery = $scope.saveQuery, + forkQuery = $scope.forkQuery; $scope.sourceMode = true; $scope.canEdit = currentUser.canEdit($scope.query) || $scope.query.can_edit;// TODO: bring this back? || clientConfig.allowAllToEditQueries; @@ -77,12 +78,23 @@ return savePromise; }; + $scope.forkQuery = function(options, data) { + var savePromise = forkQuery(options, data); + + if (!savePromise) { + return; + } + + savePromise.then(function(savedQuery) { + queryText = savedQuery.query; + }); + + return savePromise; + }; + $scope.duplicateQuery = function() { Events.record(currentUser, 'fork', 'query', $scope.query.id); - $scope.query.name = 'Copy of (#'+$scope.query.id+') '+$scope.query.name; - $scope.query.id = null; - $scope.query.schedule = null; - $scope.saveQuery({ + $scope.forkQuery({ successMessage: 'Query forked', errorMessage: 'Query could not be forked' }).then(function redirect(savedQuery) { diff --git a/rd_ui/app/scripts/controllers/query_view.js b/rd_ui/app/scripts/controllers/query_view.js index 462bbc13b..72293dcfa 100644 --- a/rd_ui/app/scripts/controllers/query_view.js +++ b/rd_ui/app/scripts/controllers/query_view.js @@ -151,7 +151,15 @@ growl.addErrorMessage(options.errorMessage); } }).$promise; - } + }; + + $scope.forkQuery = function(options, data) { + return Query.fork({id:$scope.query.id}, function() { + growl.addSuccessMessage(options.successMessage); + }, function(httpResponse) { + growl.addErrorMessage(options.errorMessage); + }).$promise; + }; $scope.saveDescription = function() { Events.record(currentUser, 'edit_description', 'query', $scope.query.id); diff --git a/rd_ui/app/scripts/services/resources.js b/rd_ui/app/scripts/services/resources.js index e6a9bc614..7606bdba7 100644 --- a/rd_ui/app/scripts/services/resources.js +++ b/rd_ui/app/scripts/services/resources.js @@ -454,6 +454,12 @@ method: 'get', isArray: false, url: "api/queries/my" + }, + fork: { + method: 'post', + isArray: false, + url: "api/queries/:id/fork", + params: {id: '@id'} } }); diff --git a/redash/handlers/api.py b/redash/handlers/api.py index 7658e2687..2db4f63c7 100644 --- a/redash/handlers/api.py +++ b/redash/handlers/api.py @@ -9,7 +9,7 @@ from redash.handlers.alerts import AlertResource, AlertListResource, AlertSubscr from redash.handlers.dashboards import DashboardListResource, RecentDashboardsResource, DashboardResource, DashboardShareResource from redash.handlers.data_sources import DataSourceTypeListResource, DataSourceListResource, DataSourceSchemaResource, DataSourceResource, DataSourcePauseResource, DataSourceTestResource from redash.handlers.events import EventResource -from redash.handlers.queries import QueryRefreshResource, QueryListResource, QueryRecentResource, QuerySearchResource, QueryResource, MyQueriesResource +from redash.handlers.queries import 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 from redash.handlers.visualizations import VisualizationListResource @@ -71,6 +71,7 @@ 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') +api.add_org_resource(QueryForkResource, '/api/queries//fork', endpoint='query_fork') api.add_org_resource(ObjectPermissionsListResource, '/api///acl', endpoint='object_permissions') api.add_org_resource(CheckPermissionResource, '/api///acl/', endpoint='check_permissions') diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py index 38d425122..649a3996a 100644 --- a/redash/handlers/queries.py +++ b/redash/handlers/queries.py @@ -139,6 +139,14 @@ class QueryResource(BaseResource): query.archive(self.current_user) +class QueryForkResource(BaseResource): + @require_permission('edit_query') + def post(self, query_id): + query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) + forked_query = query.fork(self.current_user) + return forked_query.to_dict(with_visualizations=True) + + class QueryRefreshResource(BaseResource): def post(self, query_id): query = get_object_or_404(models.Query.get_by_id_and_org, query_id, self.current_org) diff --git a/redash/models.py b/redash/models.py index 54ae72e51..18ace0ebc 100644 --- a/redash/models.py +++ b/redash/models.py @@ -843,6 +843,31 @@ class Query(ChangeTrackingMixin, ModelTimestampsMixin, BaseVersionedModel, Belon return query + def fork(self, user): + query = self + forked_query = Query() + forked_query.name = 'Copy of (#{}) {}'.format(query.id, query.name) + forked_query.user = user + forked_list = ['org', 'data_source', 'latest_query_data', 'description', 'query', 'query_hash'] + for a in forked_list: + setattr(forked_query, a, getattr(query, a)) + forked_query.save() + + forked_visualizations = [] + for v in query.visualizations: + if v.type == 'TABLE': + continue + forked_v = v.to_dict() + forked_v['options'] = v.options + forked_v['query'] = forked_query + forked_v.pop('id') + forked_visualizations.append(forked_v) + + if len(forked_visualizations) > 0: + with db.database.atomic(): + Visualization.insert_many(forked_visualizations).execute() + return forked_query + def pre_save(self, created): super(Query, self).pre_save(created) self.query_hash = utils.gen_query_hash(self.query) diff --git a/tests/models/test_queries.py b/tests/models/test_queries.py index 16a9a13f6..a6c78c9cb 100644 --- a/tests/models/test_queries.py +++ b/tests/models/test_queries.py @@ -1,5 +1,73 @@ from tests import BaseTestCase +from redash.models import Query -# Add tests for change tracking +class TestApiKeyGetByObject(BaseTestCase): + + def assert_visualizations(self, origin_q, origin_v, forked_q, forked_v): + self.assertEqual(origin_v.options, forked_v.options) + self.assertEqual(origin_v.type, forked_v.type) + self.assertNotEqual(origin_v.id, forked_v.id) + self.assertNotEqual(origin_v.query, forked_v.query) + self.assertEqual(forked_q.id, forked_v.query.id) + + + def test_fork_with_visualizations(self): + # prepare original query and visualizations + data_source = self.factory.create_data_source(group=self.factory.create_group()) + query = self.factory.create_query(data_source=data_source, description="this is description") + visualization_chart = self.factory.create_visualization(query=query, description="chart vis", type="CHART", options="""{"yAxis": [{"type": "linear"}, {"type": "linear", "opposite": true}], "series": {"stacking": null}, "globalSeriesType": "line", "sortX": true, "seriesOptions": {"count": {"zIndex": 0, "index": 0, "type": "line", "yAxis": 0}}, "xAxis": {"labels": {"enabled": true}, "type": "datetime"}, "columnMapping": {"count": "y", "created_at": "x"}, "bottomMargin": 50, "legend": {"enabled": true}}""") + visualization_box = self.factory.create_visualization(query=query, description="box vis", type="BOXPLOT", options="{}") + fork_user = self.factory.create_user() + + forked_query = query.fork(fork_user) + + + forked_visualization_chart = None + forked_visualization_box = None + forked_table = None + count_table = 0 + for v in forked_query.visualizations: + if v.description == "chart vis": + forked_visualization_chart = v + if v.description == "box vis": + forked_visualization_box = v + if v.type == "TABLE": + count_table += 1 + forked_table = v + self.assert_visualizations(query, visualization_chart, forked_query, forked_visualization_chart) + self.assert_visualizations(query, visualization_box, forked_query, forked_visualization_box) + + self.assertEqual(forked_query.org, query.org) + self.assertEqual(forked_query.data_source, query.data_source) + self.assertEqual(forked_query.latest_query_data, query.latest_query_data) + self.assertEqual(forked_query.description, query.description) + self.assertEqual(forked_query.query, query.query) + self.assertEqual(forked_query.query_hash, query.query_hash) + self.assertEqual(forked_query.user, fork_user) + self.assertEqual(forked_query.description, query.description) + self.assertTrue(forked_query.name.startswith('Copy')) + # num of TABLE must be 1. default table only + self.assertEqual(count_table, 1) + self.assertEqual(forked_table.name, "Table") + self.assertEqual(forked_table.description, "") + self.assertEqual(forked_table.options, "{}") + + def test_fork_from_query_that_has_no_visualization(self): + # prepare original query and visualizations + data_source = self.factory.create_data_source(group=self.factory.create_group()) + query = self.factory.create_query(data_source=data_source, description="this is description") + fork_user = self.factory.create_user() + + forked_query = query.fork(fork_user) + + count_table = 0 + count_vis = 0 + for v in forked_query.visualizations: + count_vis += 1 + if v.type == "TABLE": + count_table += 1 + + self.assertEqual(count_table, 1) + self.assertEqual(count_vis, 1)