+
+
diff --git a/client/app/pages/dashboards/public-dashboard-page.js b/client/app/pages/dashboards/public-dashboard-page.js
index 58047614fa..706b058b67 100644
--- a/client/app/pages/dashboards/public-dashboard-page.js
+++ b/client/app/pages/dashboards/public-dashboard-page.js
@@ -25,41 +25,47 @@ const PublicDashboardPage = {
this.logoUrl = logoUrl;
this.public = true;
- this.dashboard.widgets = Dashboard.prepareDashboardWidgets(this.dashboard.widgets);
+ this.globalParameters = [];
+
+ this.extractGlobalParameters = () => {
+ this.globalParameters = this.dashboard.getParametersDefs();
+ };
const refreshRate = Math.max(30, parseFloat($location.search().refresh));
- if (refreshRate) {
- const refresh = () => {
- loadDashboard($http, $route).then((data) => {
- this.dashboard = data;
- this.dashboard.widgets = Dashboard.prepareDashboardWidgets(this.dashboard.widgets);
- this.dashboard.widgets.forEach(widget => widget.load());
+ this.refreshDashboard = () => {
+ loadDashboard($http, $route).then((data) => {
+ this.dashboard = new Dashboard(data);
+ this.dashboard.widgets = Dashboard.prepareDashboardWidgets(this.dashboard.widgets);
+ this.dashboard.widgets.forEach((widget) => {
+ widget.load(!!refreshRate).catch((error) => {
+ const isSafe = widget.getQuery() ? widget.getQuery().is_safe : true;
+ if (!isSafe) {
+ error.errorMessage = 'This query contains potentially unsafe parameters and cannot be executed on a publicly shared dashboard.';
+ }
+ });
+ });
+ this.filters = []; // TODO: implement (@/services/dashboard.js:collectDashboardFilters)
+ this.filtersOnChange = (allFilters) => {
+ this.filters = allFilters;
+ $scope.$applyAsync();
+ };
- this.filters = []; // TODO: implement (@/services/dashboard.js:collectDashboardFilters)
- this.filtersOnChange = (allFilters) => {
- this.filters = allFilters;
- $scope.$applyAsync();
- };
+ this.extractGlobalParameters();
+ });
- $timeout(refresh, refreshRate * 1000.0);
- });
- };
+ if (refreshRate) {
+ $timeout(this.refreshDashboard, refreshRate * 1000.0);
+ }
+ };
- $timeout(refresh, refreshRate * 1000.0);
- }
+ this.refreshDashboard();
},
};
export default function init(ngModule) {
ngModule.component('publicDashboardPage', PublicDashboardPage);
- function loadPublicDashboard($http, $route) {
- 'ngInject';
-
- return loadDashboard($http, $route);
- }
-
function session($http, $route, Auth) {
const token = $route.current.params.token;
Auth.setApiKey(token);
@@ -68,10 +74,9 @@ export default function init(ngModule) {
ngModule.config(($routeProvider) => {
$routeProvider.when('/public/dashboards/:token', {
- template: '
',
+ template: '
',
reloadOnSearch: false,
resolve: {
- dashboard: loadPublicDashboard,
session,
},
});
diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js
index 69896441ac..398f96cf47 100644
--- a/client/app/services/dashboard.js
+++ b/client/app/services/dashboard.js
@@ -183,7 +183,6 @@ function DashboardService($resource, $http, $location, currentUser) {
resource.prepareDashboardWidgets = prepareDashboardWidgets;
resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
-
resource.prototype.getParametersDefs = function getParametersDefs() {
const globalParams = {};
const queryParams = $location.search();
@@ -192,7 +191,7 @@ function DashboardService($resource, $http, $location, currentUser) {
const mappings = widget.getParameterMappings();
widget
.getQuery()
- .getParametersDefs()
+ .getParametersDefs(false)
.forEach((param) => {
const mapping = mappings[param.name];
if (mapping.type === Widget.MappingType.DashboardLevel) {
diff --git a/client/app/services/query.js b/client/app/services/query.js
index 8c00829ca1..65161d9c22 100644
--- a/client/app/services/query.js
+++ b/client/app/services/query.js
@@ -219,25 +219,32 @@ class Parameters {
}
parseQuery() {
+ const fallback = () => map(this.query.options.parameters, i => i.name);
+
let parameters = [];
- try {
- const parts = Mustache.parse(this.query.query);
- parameters = uniq(collectParams(parts));
- } catch (e) {
- logger('Failed parsing parameters: ', e);
- // Return current parameters so we don't reset the list
- parameters = map(this.query.options.parameters, i => i.name);
+ if (this.query.query) {
+ try {
+ const parts = Mustache.parse(this.query.query);
+ parameters = uniq(collectParams(parts));
+ } catch (e) {
+ logger('Failed parsing parameters: ', e);
+ // Return current parameters so we don't reset the list
+ parameters = fallback();
+ }
+ } else {
+ parameters = fallback();
}
+
return parameters;
}
- updateParameters() {
- if (this.query.query === this.cachedQueryText) {
+ updateParameters(update) {
+ if (this.query.query && this.query.query === this.cachedQueryText) {
return;
}
this.cachedQueryText = this.query.query;
- const parameterNames = this.parseQuery();
+ const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, p => p.name);
this.query.options.parameters = this.query.options.parameters || [];
@@ -269,8 +276,8 @@ class Parameters {
});
}
- get() {
- this.updateParameters();
+ get(update = true) {
+ this.updateParameters(update);
return this.query.options.parameters;
}
@@ -478,10 +485,6 @@ function QueryResource(
};
QueryService.prototype.prepareQueryResultExecution = function prepareQueryResultExecution(execute, maxAge) {
- if (!this.query) {
- return new QueryResultError("Can't execute empty query.");
- }
-
const parameters = this.getParameters();
const missingParams = parameters.getMissing();
@@ -517,10 +520,8 @@ function QueryResource(
if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.id, this.latest_query_data_id);
}
- } else if (this.data_source_id) {
- this.queryResult = execute();
} else {
- return new QueryResultError('Please select data source to run this query.');
+ this.queryResult = execute();
}
return this.queryResult;
@@ -533,6 +534,10 @@ function QueryResource(
QueryService.prototype.getQueryResultByText = function getQueryResultByText(maxAge, selectedQueryText) {
const queryText = selectedQueryText || this.query;
+ if (!queryText) {
+ return new QueryResultError("Can't execute empty query.");
+ }
+
const parameters = this.getParameters().getValues();
const execute = () => QueryResult.get(this.data_source_id, queryText, parameters, maxAge, this.id);
return this.prepareQueryResultExecution(execute, maxAge);
@@ -576,8 +581,8 @@ function QueryResource(
return this.$parameters;
};
- QueryService.prototype.getParametersDefs = function getParametersDefs() {
- return this.getParameters().get();
+ QueryService.prototype.getParametersDefs = function getParametersDefs(update = true) {
+ return this.getParameters().get(update);
};
return QueryService;
diff --git a/client/app/services/widget.js b/client/app/services/widget.js
index ecc8b36300..a682879c62 100644
--- a/client/app/services/widget.js
+++ b/client/app/services/widget.js
@@ -212,7 +212,7 @@ function WidgetFactory($http, $location, Query) {
const existingParams = {};
// textboxes does not have query
- const params = this.getQuery() ? this.getQuery().getParametersDefs() : [];
+ const params = this.getQuery() ? this.getQuery().getParametersDefs(false) : [];
each(params, (param) => {
existingParams[param.name] = true;
if (!isObject(this.options.parameterMappings[param.name])) {
diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js
index 2d966432b5..2a21b5f3ac 100644
--- a/client/cypress/integration/dashboard/dashboard_spec.js
+++ b/client/cypress/integration/dashboard/dashboard_spec.js
@@ -109,6 +109,154 @@ describe('Dashboard', () => {
});
});
+ describe('Sharing', () => {
+ beforeEach(function () {
+ createDashboard('Foo Bar').then(({ slug, id }) => {
+ this.dashboardId = id;
+ this.dashboardUrl = `/dashboard/${slug}`;
+ });
+ });
+
+ it('is possible if all queries are safe', function () {
+ const options = {
+ parameters: [{
+ name: 'foo',
+ type: 'number',
+ }],
+ };
+
+ const dashboardUrl = this.dashboardUrl;
+ createQuery({ options }).then(({ id: queryId }) => {
+ cy.visit(dashboardUrl);
+ editDashboard();
+ cy.contains('a', 'Add Widget').click();
+ cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
+ });
+ cy.contains('button', 'Add to Dashboard').click();
+ cy.getByTestId('AddWidgetDialog').should('not.exist');
+ cy.clickThrough({ button: `
+ Done Editing
+ Publish
+ ` },
+ `OpenShareForm
+ PublicAccessEnabled`);
+
+ cy.getByTestId('SecretAddress').should('exist');
+ });
+ });
+
+ describe('is available to unauthenticated users', () => {
+ const addWidgetAndShareDashboard = (dashboardUrl, query, options, callback) => {
+ createQuery({ query, options }).then(({ id: queryId }) => {
+ cy.visit(dashboardUrl);
+ editDashboard();
+ cy.contains('a', 'Add Widget').click();
+ cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
+ });
+ cy.contains('button', 'Add to Dashboard').click();
+ cy.getByTestId('AddWidgetDialog').should('not.exist');
+ cy.clickThrough({ button: `
+ Done Editing
+ Publish
+ ` },
+ `OpenShareForm
+ PublicAccessEnabled`);
+
+ cy.getByTestId('SecretAddress').invoke('val').then((secretAddress) => {
+ callback(secretAddress);
+ });
+ });
+ };
+
+ it.only('when there are no parameters', function () {
+ addWidgetAndShareDashboard(this.dashboardUrl, 'select 1', {}, (secretAddress) => {
+ cy.logout();
+ cy.visit(secretAddress);
+ cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist');
+ cy.percySnapshot('Successfully Shared Unparameterized Dashboard');
+ });
+ });
+
+ it('when there are only safe parameters', function () {
+ addWidgetAndShareDashboard(this.dashboardUrl, "select '{{foo}}'", {
+ parameters: [{
+ name: 'foo',
+ type: 'number',
+ value: 1,
+ }],
+ }, (secretAddress) => {
+ cy.logout();
+ cy.visit(secretAddress);
+ cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist');
+ cy.percySnapshot('Successfully Shared Parameterized Dashboard');
+ });
+ });
+
+ it('even when there are suddenly some unsafe parameters', function () {
+ // start out by creating a dashboard with no parameters & share it
+ const dashboardUrl = this.dashboardUrl;
+ addWidgetAndShareDashboard(dashboardUrl, 'select 1', {}, (secretAddress) => {
+ // then, after it is shared, add an unsafe parameterized query to it
+ createQuery({
+ query: "select '{{foo}}'",
+ options: {
+ parameters: [{
+ name: 'foo',
+ type: 'text',
+ value: 'oh snap!',
+ }],
+ },
+ }).then(({ id: queryId }) => {
+ cy.visit(dashboardUrl);
+ editDashboard();
+ cy.contains('a', 'Add Widget').click();
+ cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
+ });
+ cy.contains('button', 'Add to Dashboard').click();
+ cy.getByTestId('AddWidgetDialog').should('not.exist');
+ cy.contains('button', 'Done Editing').click();
+ cy.logout();
+ cy.visit(secretAddress);
+ cy.getByTestId('DynamicTable', { timeout: 10000 }).should('exist');
+ cy.percySnapshot('Successfully Shared Parameterized Dashboard With Some Unsafe Queries');
+ });
+ });
+ });
+ });
+
+ it('is not possible if some queries are not safe', function () {
+ const options = {
+ parameters: [{
+ name: 'foo',
+ type: 'text',
+ }],
+ };
+
+ const dashboardUrl = this.dashboardUrl;
+ createQuery({ options }).then(({ id: queryId }) => {
+ cy.visit(dashboardUrl);
+ editDashboard();
+ cy.contains('a', 'Add Widget').click();
+ cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
+ });
+ cy.contains('button', 'Add to Dashboard').click();
+ cy.getByTestId('AddWidgetDialog').should('not.exist');
+ cy.clickThrough({ button: `
+ Done Editing
+ Publish
+ ` },
+ 'OpenShareForm');
+
+ cy.getByTestId('PublicAccessEnabled').should('be.disabled');
+ });
+ });
+ });
+
+
describe('Textbox', () => {
beforeEach(function () {
createDashboard('Foo Bar').then(({ slug, id }) => {
@@ -425,6 +573,13 @@ describe('Dashboard', () => {
const paramName = 'count';
const queryData = {
query: `select s.a FROM generate_series(1,{{ ${paramName} }}) AS s(a)`,
+ options: {
+ parameters: [{
+ title: paramName,
+ name: paramName,
+ type: 'text',
+ }],
+ },
};
beforeEach(function () {
diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js
index 87190b7855..dd8df75ea8 100644
--- a/client/cypress/integration/query/parameter_spec.js
+++ b/client/cypress/integration/query/parameter_spec.js
@@ -131,7 +131,7 @@ describe('Parameter', () => {
ParameterTypeSelect
DateParameterTypeOption
UseCurrentDateTimeCheckbox
- SaveParameterSettings
+ SaveParameterSettings
`);
const now = new Date();
diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js
index 97efba27f2..37654fccdf 100644
--- a/client/cypress/support/commands.js
+++ b/client/cypress/support/commands.js
@@ -16,12 +16,32 @@ Cypress.Commands.add('login', (email = 'admin@redash.io', password = 'password')
Cypress.Commands.add('logout', () => cy.request('/logout'));
Cypress.Commands.add('getByTestId', element => cy.get('[data-test="' + element + '"]'));
-Cypress.Commands.add('clickThrough', (elements) => {
- elements
- .trim()
- .split(/\s/)
- .filter(Boolean)
- .forEach(element => cy.getByTestId(element).click());
+
+/* Clicks a series of elements. Pass in a newline-seperated string in order to click all elements by their test id,
+ or enclose the above string in an object with 'button' as key to click the buttons by name. For example:
+
+ cy.clickThrough(`
+ TestId1
+ TestId2
+ TestId3
+ `, { button: `
+ Label of button 4
+ Label of button 5
+ ` }, `
+ TestId6
+ TestId7`);
+*/
+Cypress.Commands.add('clickThrough', (...args) => {
+ args.forEach((elements) => {
+ const names = elements.button || elements;
+
+ const click = element => (elements.button ?
+ cy.contains('button', element.trim()) :
+ cy.getByTestId(element.trim())).click();
+
+ names.trim().split(/\n/).filter(Boolean).forEach(click);
+ });
+
return undefined;
});
diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py
index 461e033e5a..6c002106c2 100644
--- a/redash/handlers/authentication.py
+++ b/redash/handlers/authentication.py
@@ -259,6 +259,18 @@ def messages():
return messages
+def messages():
+ messages = []
+
+ if not current_user.is_email_verified:
+ messages.append('email-not-verified')
+
+ if settings.ALLOW_PARAMETERS_IN_EMBEDS:
+ messages.append('using-deprecated-embed-feature')
+
+ return messages
+
+
@routes.route('/api/config', methods=['GET'])
def config(org_slug=None):
return json_response({
diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py
index ec890b2558..c9a48f38dc 100644
--- a/redash/handlers/queries.py
+++ b/redash/handlers/queries.py
@@ -186,6 +186,7 @@ def require_access_to_dropdown_queries(user, query_def):
require_access(dict(groups), user, view_only)
+
class QueryListResource(BaseQueryListResource):
@require_permission('create_query')
def post(self):
diff --git a/redash/handlers/query_results.py b/redash/handlers/query_results.py
index 0899c0e7a5..815a7a154d 100644
--- a/redash/handlers/query_results.py
+++ b/redash/handlers/query_results.py
@@ -88,7 +88,11 @@ def post(self):
parameterized_query = ParameterizedQuery(query)
- data_source = models.DataSource.get_by_id_and_org(params.get('data_source_id'), self.current_org)
+ data_source_id = params.get('data_source_id')
+ if data_source_id:
+ data_source = models.DataSource.get_by_id_and_org(params.get('data_source_id'), self.current_org)
+ else:
+ return {'job': {'status': 4, 'error': 'Please select data source to run this query.'}}, 401
if not has_access(data_source, self.current_user, not_view_only):
return {'job': {'status': 4, 'error': 'You do not have permission to run queries with this data source.'}}, 403
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
index 5cecdbc530..df1bae38d9 100644
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -690,6 +690,20 @@ def parameters(self):
def parameterized(self):
return ParameterizedQuery(self.query_text, self.parameters)
+ @property
+ def dashboard_api_keys(self):
+ query = """SELECT api_keys.api_key
+ FROM api_keys
+ JOIN dashboards ON object_id = dashboards.id
+ JOIN widgets ON dashboards.id = widgets.dashboard_id
+ JOIN visualizations ON widgets.visualization_id = visualizations.id
+ WHERE object_type='dashboards'
+ AND active=true
+ AND visualizations.query_id = :id"""
+
+ api_keys = db.session.execute(query, {'id': self.id}).fetchall()
+ return [api_key[0] for api_key in api_keys]
+
@listens_for(Query.query_text, 'set')
def gen_query_hash(target, val, oldval, initiator):
diff --git a/redash/permissions.py b/redash/permissions.py
index 9de65efd55..d928d918c9 100644
--- a/redash/permissions.py
+++ b/redash/permissions.py
@@ -16,13 +16,19 @@
def has_access(obj, user, need_view_only):
if hasattr(obj, 'api_key') and user.is_api_user():
- return has_access_to_object(obj, user, need_view_only)
+ return has_access_to_object(obj, user.id, need_view_only)
else:
return has_access_to_groups(obj, user, need_view_only)
-def has_access_to_object(obj, user, need_view_only):
- return (obj.api_key == user.id) and need_view_only
+def has_access_to_object(obj, api_key, need_view_only):
+ if obj.api_key == api_key:
+ return need_view_only
+ elif hasattr(obj, 'dashboard_api_keys'):
+ # check if api_key belongs to a dashboard containing this query
+ return api_key in obj.dashboard_api_keys and need_view_only
+ else:
+ return False
def has_access_to_groups(obj, user, need_view_only):
diff --git a/redash/serializers/__init__.py b/redash/serializers/__init__.py
index 605a3baa9a..ba51ad0eb1 100644
--- a/redash/serializers/__init__.py
+++ b/redash/serializers/__init__.py
@@ -25,21 +25,20 @@ def public_widget(widget):
'created_at': widget.created_at
}
- if widget.visualization and widget.visualization.id:
- query_data = models.QueryResult.query.get(widget.visualization.query_rel.latest_query_data_id).to_dict()
+ v = widget.visualization
+ if v and v.id:
res['visualization'] = {
- 'type': widget.visualization.type,
- 'name': widget.visualization.name,
- 'description': widget.visualization.description,
- 'options': json_loads(widget.visualization.options),
- 'updated_at': widget.visualization.updated_at,
- 'created_at': widget.visualization.created_at,
+ 'type': v.type,
+ 'name': v.name,
+ 'description': v.description,
+ 'options': json_loads(v.options),
+ 'updated_at': v.updated_at,
+ 'created_at': v.created_at,
'query': {
- 'query': ' ', # workaround, as otherwise the query data won't be loaded.
- 'name': widget.visualization.query_rel.name,
- 'description': widget.visualization.query_rel.description,
- 'options': {},
- 'latest_query_data': query_data
+ 'id': v.query_rel.id,
+ 'name': v.query_rel.name,
+ 'description': v.query_rel.description,
+ 'options': v.query_rel.options
}
}
diff --git a/redash/tasks/queries.py b/redash/tasks/queries.py
index f322b51052..b54c831b55 100644
--- a/redash/tasks/queries.py
+++ b/redash/tasks/queries.py
@@ -289,11 +289,15 @@ class QueryExecutionError(Exception):
pass
-def _resolve_user(user_id, is_api_key):
+def _resolve_user(user_id, is_api_key, query_id):
if user_id is not None:
if is_api_key:
api_key = user_id
- q = models.Query.by_api_key(api_key)
+ if query_id is not None:
+ q = models.Query.get_by_id(query_id)
+ else:
+ q = models.Query.by_api_key(api_key)
+
return models.ApiUser(api_key, q.org, q.groups)
else:
return models.User.get_by_id(user_id)
@@ -311,7 +315,7 @@ def __init__(self, task, query, data_source_id, user_id, is_api_key, metadata,
self.data_source_id = data_source_id
self.metadata = metadata
self.data_source = self._load_data_source()
- self.user = _resolve_user(user_id, is_api_key)
+ self.user = _resolve_user(user_id, is_api_key, metadata.get('Query ID'))
# Close DB connection to prevent holding a connection for a long time while the query is executing.
models.db.session.close()
diff --git a/tests/handlers/test_query_results.py b/tests/handlers/test_query_results.py
index 8d9add7ec1..6bf8815bf4 100644
--- a/tests/handlers/test_query_results.py
+++ b/tests/handlers/test_query_results.py
@@ -105,6 +105,15 @@ def test_execute_on_paused_data_source(self):
self.assertNotIn('query_result', rv.json)
self.assertIn('job', rv.json)
+ def test_execute_without_data_source(self):
+ rv = self.make_request('post', '/api/query_results',
+ data={'query': 'SELECT 1',
+ 'max_age': 0})
+
+ self.assertEquals(rv.status_code, 401)
+ self.assertNotIn('query_result', rv.json)
+ self.assertIn('job', rv.json)
+
class TestQueryResultAPI(BaseTestCase):
def test_has_no_access_to_data_source(self):
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
index b2a65076c4..11cffd1be4 100644
--- a/tests/test_permissions.py
+++ b/tests/test_permissions.py
@@ -1,13 +1,15 @@
+from tests import BaseTestCase
from collections import namedtuple
from unittest import TestCase
from redash.permissions import has_access
+from redash import models
MockUser = namedtuple('MockUser', ['permissions', 'group_ids'])
view_only = True
-class TestHasAccess(TestCase):
+class TestHasAccess(BaseTestCase):
def test_allows_admin_regardless_of_groups(self):
user = MockUser(['admin'], [])
@@ -39,3 +41,27 @@ def test_not_allows_if_not_enough_permission(self):
self.assertFalse(has_access({2: view_only}, user, not view_only))
self.assertFalse(has_access({2: view_only}, user, view_only))
self.assertFalse(has_access({2: not view_only, 1: view_only}, user, not view_only))
+
+ def test_allows_access_to_query_by_query_api_key(self):
+ query = self.factory.create_query()
+ user = models.ApiUser(query.api_key, None, [])
+
+ self.assertTrue(has_access(query, user, view_only))
+
+ def test_doesnt_allow_access_to_query_by_different_api_key(self):
+ query = self.factory.create_query()
+ other_query = self.factory.create_query()
+ user = models.ApiUser(other_query.api_key, None, [])
+
+ self.assertFalse(has_access(query, user, view_only))
+
+ def test_allows_access_to_query_by_dashboard_api_key(self):
+ dashboard = self.factory.create_dashboard()
+ visualization = self.factory.create_visualization()
+ self.factory.create_widget(dashboard=dashboard, visualization=visualization)
+ query = self.factory.create_query(visualizations=[visualization])
+
+ api_key = self.factory.create_api_key(object=dashboard).api_key
+ user = models.ApiUser(api_key, None, [])
+
+ self.assertTrue(has_access(query, user, view_only))
\ No newline at end of file