Compare commits

...

30 Commits

Author SHA1 Message Date
Arik Fraimovich
e80e52f6c9 Add annotations for the injector. 2015-04-01 20:23:18 +03:00
Arik Fraimovich
fe41a70602 Merge pull request #400 from EverythingMe/feature/better_scheduler
Improved query scheduling option
2015-04-01 17:28:02 +03:00
Arik Fraimovich
976d9abe2d Disable UI tests, as they are no longer maintained :-( 2015-04-01 17:23:08 +03:00
Arik Fraimovich
041bc1100a New UI for query schedule setting 2015-04-01 17:07:19 +03:00
Arik Fraimovich
5d095ff6ab Resolve #113: upgrade to latest ui-bootstrap 2015-04-01 12:48:24 +03:00
Arik Fraimovich
ef01b61b29 Fix: refresh selector had empty option 2015-04-01 12:11:14 +03:00
Arik Fraimovich
faad6b656b Change query ttl field to be a string and named schedule.
This to allow other types of scheduling than just repeat every X seconds.
The first supported option will be: repeat every day at hour X.
2015-04-01 11:23:26 +03:00
Arik Fraimovich
0bc775584b Merge pull request #397 from EverythingMe/feature/edit_others_queries
Fix: forking broken
2015-03-22 17:32:13 +02:00
Arik Fraimovich
f2d96d61a1 Fix: forking broken 2015-03-22 17:28:47 +02:00
Arik Fraimovich
09bf2dd608 Merge pull request #396 from EverythingMe/feature/edit_others_queries
Feature: allow editing others' queries
2015-03-22 14:53:02 +02:00
Arik Fraimovich
ad1b9b06cf Fix test. 2015-03-22 14:42:08 +02:00
Arik Fraimovich
a4bceae60b Allow anyone to edit any query & show who edited it 2015-03-22 13:22:11 +02:00
Arik Fraimovich
9385449feb Add updated_at timestamp to visualization, query, dashboard and users models 2015-03-22 12:58:26 +02:00
Arik Fraimovich
562e1bb8c9 Merge pull request #395 from EverythingMe/feature/post_to_create_a_query
Convert additional dates to user's formatting
2015-03-19 08:54:54 +02:00
Arik Fraimovich
082b718303 Convert additional dates to user's formatting 2015-03-19 08:54:04 +02:00
Arik Fraimovich
c0872899e9 Merge pull request #394 from EverythingMe/feature/post_to_create_a_query
Fix: column definitions weren't updated.
2015-03-19 08:43:17 +02:00
Arik Fraimovich
086bbf129d Fix: column definitions weren't udpated 2015-03-19 08:40:21 +02:00
Arik Fraimovich
4b7561e538 Merge pull request #393 from EverythingMe/feature/post_to_create_a_query
Fix: allow Unicode and other special chars in column names
2015-03-19 08:34:06 +02:00
Arik Fraimovich
407c5a839b Fix: allow Unicode and other special chars in column names
Stopped using Angular's $parse and just accessing the property directly.
2015-03-19 08:33:16 +02:00
Arik Fraimovich
b8aefd26b8 Merge pull request #392 from EverythingMe/feature/post_to_create_a_query
Support posting to /queries/new to create a new query.
2015-03-18 13:42:55 +02:00
Arik Fraimovich
85a762bcd2 Support posting to /queries/new to create a new query. 2015-03-18 13:28:23 +02:00
Arik Fraimovich
4f1b3d5beb Merge pull request #391 from EverythingMe/feature/api_key_auth
Fix: allow dots in column name
2015-03-16 15:08:00 +02:00
Arik Fraimovich
9218a7c437 Fix: allow dots in column name 2015-03-16 14:59:51 +02:00
Arik Fraimovich
71a3f066a5 Ignore gh-pages branch in CircleCI. 2015-03-16 09:03:52 +02:00
Arik Fraimovich
89436d779c Merge pull request #390 from fedex1/patch-1
Update bootstrap.sh
2015-03-16 05:37:33 +02:00
Ralph Yozzo
3631e938da Update bootstrap.sh
# modified by @fedex1 3/15/2015 seems to be the latest version at this point in time.
2015-03-15 23:27:45 -04:00
Arik Fraimovich
c0a9db68f0 Merge pull request #389 from EverythingMe/feature/api_key_auth
Fix: show date/time with respect to user's locale
2015-03-15 18:53:31 +02:00
Arik Fraimovich
bec9c9e14e Fix: show date/time in user's locale 2015-03-15 18:53:02 +02:00
Arik Fraimovich
47bbc25277 Merge pull request #388 from EverythingMe/feature/api_key_auth
Make it possible to set enabled query runners from env
2015-03-12 12:00:26 +02:00
Arik Fraimovich
f02c2588d2 Make it possible to set enabled query runners from env 2015-03-12 11:52:31 +02:00
31 changed files with 544 additions and 190 deletions

View File

@@ -19,4 +19,4 @@ upload:
test:
nosetests --with-coverage --cover-package=redash tests/*.py
cd rd_ui && grunt test
#cd rd_ui && grunt test

View File

@@ -26,3 +26,7 @@ deployment:
notify:
webhooks:
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
general:
branches:
ignore:
- gh-pages

View File

@@ -0,0 +1,26 @@
from playhouse.migrate import PostgresqlMigrator, migrate
from redash.models import db
from redash import models
if __name__ == '__main__':
db.connect_db()
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
migrate(
migrator.add_column('queries', 'updated_at', models.Query.updated_at),
migrator.add_column('dashboards', 'updated_at', models.Dashboard.updated_at),
migrator.add_column('widgets', 'updated_at', models.Widget.updated_at),
migrator.add_column('users', 'created_at', models.User.created_at),
migrator.add_column('users', 'updated_at', models.User.updated_at),
migrator.add_column('visualizations', 'created_at', models.Visualization.created_at),
migrator.add_column('visualizations', 'updated_at', models.Visualization.updated_at)
)
db.database.execute_sql("UPDATE queries SET updated_at = created_at;")
db.database.execute_sql("UPDATE dashboards SET updated_at = created_at;")
db.database.execute_sql("UPDATE widgets SET updated_at = created_at;")
db.close_db(None)

View File

@@ -0,0 +1,19 @@
from playhouse.migrate import PostgresqlMigrator, migrate
from redash.models import db
from redash import models
if __name__ == '__main__':
db.connect_db()
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
migrate(
migrator.add_column('queries', 'last_modified_by_id', models.Query.last_modified_by)
)
db.database.execute_sql("UPDATE queries SET last_modified_by_id = user_id;")
db.close_db(None)

View File

@@ -0,0 +1,23 @@
from playhouse.migrate import PostgresqlMigrator, migrate
from redash.models import db
from redash import models
if __name__ == '__main__':
db.connect_db()
migrator = PostgresqlMigrator(db.database)
with db.database.transaction():
migrate(
migrator.add_column('queries', 'schedule', models.Query.schedule),
)
db.database.execute_sql("UPDATE queries SET schedule = ttl WHERE ttl > 0;")
migrate(
migrator.drop_column('queries', 'ttl')
)
db.close_db(None)

View File

@@ -39,9 +39,9 @@
<div class="collapse navbar-collapse navbar-ex1-collapse">
<ul class="nav navbar-nav">
<li class="active" ng-show="pageTitle"><a class="page-title" ng-bind="pageTitle"></a></li>
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')">
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
<ul class="dropdown-menu">
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')" dropdown>
<a href="#" class="dropdown-toggle" dropdown-toggle><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
<ul class="dropdown-menu" dropdown-menu>
<span ng-repeat="(name, group) in groupedDashboards">
<li class="dropdown-submenu">
<a href="#" ng-bind="name"></a>
@@ -59,9 +59,9 @@
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
</ul>
</li>
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Queries <b class="caret"></b></a>
<ul class="dropdown-menu">
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')" dropdown>
<a href="#" class="dropdown-toggle" dropdown-toggle>Queries <b class="caret"></b></a>
<ul class="dropdown-menu" dropdown-menu>
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
<li><a href="/queries">Queries</a></li>
</ul>
@@ -123,7 +123,7 @@
<script src="/bower_components/marked/lib/marked.js"></script>
<script src="/scripts/ng_highchart.js"></script>
<script src="/scripts/ng_smart_table.js"></script>
<script src="/scripts/ui-bootstrap-tpls-0.5.0.min.js"></script>
<script src="/bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
<script src="/bower_components/bucky/bucky.js"></script>
<script src="/bower_components/pace/pace.js"></script>
<script src="/bower_components/mustache/mustache.js"></script>

View File

@@ -1,4 +1,11 @@
(function () {
var dateFormatter = function (value) {
if (!value) {
return "-";
}
return value.toDate().toLocaleString();
};
var QuerySearchCtrl = function($scope, $location, $filter, Events, Query) {
$scope.$parent.pageTitle = "Queries Search";
@@ -8,11 +15,6 @@
maxSize: 8,
};
var dateFormatter = function (value) {
if (!value) return "-";
return value.format("DD/MM/YY HH:mm");
}
$scope.gridColumns = [
{
"label": "Name",
@@ -21,7 +23,7 @@
},
{
'label': 'Created By',
'map': 'user.name'
'map': 'user_name'
},
{
'label': 'Created At',
@@ -30,9 +32,9 @@
},
{
'label': 'Update Schedule',
'map': 'ttl',
'map': 'schedule',
'formatFunction': function (value) {
return $filter('refreshRateHumanize')(value);
return $filter('scheduleHumanize')(value);
}
}
];
@@ -43,6 +45,7 @@
Query.search({q: $scope.term }, function(results) {
$scope.queries = _.map(results, function(query) {
query.created_at = moment(query.created_at);
query.user_name = query.user.name;
return query;
});
});
@@ -70,11 +73,6 @@
$scope.allQueries = [];
$scope.queries = [];
var dateFormatter = function (value) {
if (!value) return "-";
return value.format("DD/MM/YY HH:mm");
}
var filterQueries = function () {
$scope.queries = _.filter($scope.allQueries, function (query) {
if (!$scope.selectedTab) {
@@ -95,6 +93,7 @@
$scope.allQueries = _.map(queries, function (query) {
query.created_at = moment(query.created_at);
query.retrieved_at = moment(query.retrieved_at);
query.user_name = query.user.name;
return query;
});
@@ -109,7 +108,7 @@
},
{
'label': 'Created By',
'map': 'user.name'
'map': 'user_name'
},
{
'label': 'Created At',
@@ -130,9 +129,9 @@
},
{
'label': 'Update Schedule',
'map': 'ttl',
'map': 'schedule',
'formatFunction': function (value) {
return $filter('refreshRateHumanize')(value);
return $filter('scheduleHumanize')(value);
}
}
]

View File

@@ -100,9 +100,13 @@
Events.record(currentUser, "autorefresh", "dashboard", dashboard.id, {'enable': $scope.refreshEnabled});
if ($scope.refreshEnabled) {
var refreshRate = _.min(_.flatten($scope.dashboard.widgets), function(widget) {
return widget.visualization.query.ttl;
}).visualization.query.ttl;
var refreshRate = _.min(_.map(_.flatten($scope.dashboard.widgets), function(widget) {
var schedule = widget.visualization.query.schedule;
if (schedule === null || schedule.match(/\d\d:\d\d/) !== null) {
return 60;
}
return widget.visualization.query.schedule;
}));
$scope.refreshRate = _.max([120, refreshRate * 2]) * 1000;
@@ -138,7 +142,6 @@
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
var maxAge = $location.search()['maxAge'];
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
$scope.nextUpdateTime = moment(new Date(($scope.query.updated_at + $scope.query.ttl + $scope.query.runtime + 300) * 1000)).fromNow();
$scope.type = 'visualization';
} else {

View File

@@ -17,7 +17,7 @@
saveQuery = $scope.saveQuery;
$scope.sourceMode = true;
$scope.canEdit = currentUser.canEdit($scope.query);
$scope.canEdit = true;
$scope.isDirty = false;
$scope.newVisualization = undefined;
@@ -68,7 +68,7 @@
$scope.duplicateQuery = function() {
Events.record(currentUser, 'fork', 'query', $scope.query.id);
$scope.query.id = null;
$scope.query.ttl = -1;
$scope.query.schedule = null;
$scope.saveQuery({
successMessage: 'Query forked',

View File

@@ -1,16 +1,21 @@
(function() {
'use strict';
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, Query, DataSource) {
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
var DEFAULT_TAB = 'table';
var getQueryResult = function(ttl) {
var getQueryResult = function(maxAge) {
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
if (ttl == undefined) {
ttl = $location.search()['maxAge'];
if (maxAge == undefined) {
maxAge = $location.search()['maxAge'];
}
$scope.queryResult = $scope.query.getQueryResult(ttl, parameters);
if (maxAge == undefined) {
maxAge = -1;
}
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
}
$scope.query = $route.current.locals.query;
@@ -18,7 +23,7 @@
getQueryResult();
$scope.queryExecuting = false;
$scope.isQueryOwner = currentUser.id === $scope.query.user.id;
$scope.isQueryOwner = (currentUser.id === $scope.query.user.id) || currentUser.hasPermission('admin');
$scope.canViewSource = currentUser.hasPermission('view_source');
$scope.dataSources = DataSource.get(function(dataSources) {
@@ -98,7 +103,7 @@
return Query.delete({id: data.id}, function() {
$scope.query.is_archived = true;
$scope.query.ttl = -1;
$scope.query.schedule = null;
growl.addSuccessMessage(options.successMessage);
// This feels dirty.
$('#archive-confirmation-modal').modal('hide');
@@ -168,6 +173,28 @@
}
});
$scope.openScheduleForm = function() {
if (!$scope.isQueryOwner) {
return;
};
$modal.open({
templateUrl: '/views/schedule_form.html',
size: 'sm',
scope: $scope,
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
$scope.close = function() {
$modalInstance.close();
}
if ($scope.query.hasDailySchedule()) {
$scope.refreshType = 'daily';
} else {
$scope.refreshType = 'periodic';
}
}]
});
};
$scope.$watch(function() {
return $location.hash()
}, function(hash) {
@@ -180,5 +207,5 @@
angular.module('redash.controllers')
.controller('QueryViewCtrl',
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', 'Query', 'DataSource', QueryViewCtrl]);
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', QueryViewCtrl]);
})();

View File

@@ -111,42 +111,91 @@
}
}
function queryTimePicker() {
return {
restrict: 'E',
template: '<select ng-disabled="refreshType != \'daily\'" ng-model="hour" ng-change="updateSchedule()" ng-options="c as c for c in hourOptions"></select> :\
<select ng-disabled="refreshType != \'daily\'" ng-model="minute" ng-change="updateSchedule()" ng-options="c as c for c in minuteOptions"></select>',
link: function($scope) {
var padWithZeros = function(size, v) {
v = String(v);
if (v.length < size) {
v = "0" + v;
}
return v;
};
$scope.hourOptions = _.map(_.range(0, 24), _.partial(padWithZeros, 2));
$scope.minuteOptions = _.map(_.range(0, 60, 5), _.partial(padWithZeros, 2));
if ($scope.query.hasDailySchedule()) {
var parts = $scope.query.scheduleInLocalTime().split(':');
$scope.minute = parts[1];
$scope.hour = parts[0];
} else {
$scope.minute = "15";
$scope.hour = "00";
}
$scope.updateSchedule = function() {
var newSchedule = moment($scope.hour + ":" + $scope.minute, 'HH:mm').utc().format('HH:mm');
if (newSchedule != $scope.query.schedule) {
$scope.query.schedule = newSchedule;
$scope.saveQuery();
}
};
$scope.$watch('refreshType', function() {
if ($scope.refreshType == 'daily') {
$scope.updateSchedule();
}
});
}
}
}
function queryRefreshSelect() {
return {
restrict: 'E',
template: '<select\
ng-disabled="!isQueryOwner"\
ng-model="query.ttl"\
ng-disabled="refreshType != \'periodic\'"\
ng-model="query.schedule"\
ng-change="saveQuery()"\
ng-options="c.value as c.name for c in refreshOptions">\
<option value="">No Refresh</option>\
</select>',
link: function($scope) {
$scope.refreshOptions = [
{
value: -1,
name: 'No Refresh'
},
{
value: 60,
value: "60",
name: 'Every minute'
},
}
]
_.each(_.range(1, 13), function (i) {
$scope.refreshOptions.push({
value: i * 3600,
value: String(i * 3600),
name: 'Every ' + i + 'h'
});
})
$scope.refreshOptions.push({
value: 24 * 3600,
value: String(24 * 3600),
name: 'Every 24h'
});
$scope.refreshOptions.push({
value: 7 * 24 * 3600,
value: String(7 * 24 * 3600),
name: 'Once a week'
});
$scope.$watch('refreshType', function() {
if ($scope.refreshType == 'periodic') {
if ($scope.query.hasDailySchedule()) {
$scope.query.schedule = null;
$scope.saveQuery();
}
}
});
}
}
@@ -158,5 +207,6 @@
.directive('queryResultLink', queryResultCSVLink)
.directive('queryEditor', queryEditor)
.directive('queryRefreshSelect', queryRefreshSelect)
.directive('queryTimePicker', queryTimePicker)
.directive('queryFormatter', ['$http', queryFormatter]);
})();

View File

@@ -24,13 +24,16 @@ angular.module('redash.filters', []).
return durationHumanize;
})
.filter('refreshRateHumanize', function () {
return function (ttl) {
if (ttl == -1) {
.filter('scheduleHumanize', function() {
return function (schedule) {
if (schedule === null) {
return "Never";
} else {
return "Every " + durationHumanize(ttl);
} else if (schedule.match(/\d\d:\d\d/) !== null) {
var localTime = moment.utc(schedule, 'HH:mm').local().format('HH:mm');
return "Every day at " + localTime;
}
return "Every " + durationHumanize(parseInt(schedule));
}
})

View File

@@ -50,7 +50,7 @@
;
if (moment.isMoment(this.x)) {
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
var s = '<b>' + this.x.toDate().toLocaleString() + '</b>',
pointsCount = this.points.length;
$.each(this.points, function (i, point) {

View File

@@ -91,7 +91,7 @@
//insert columns from column config
//TODO add a way to clean all columns
scope.$watch('columnCollection', function (oldValue, newValue) {
scope.$watchCollection('columnCollection', function (oldValue, newValue) {
if (scope.columnCollection) {
scope.columns.length = 0;
for (var i = 0, l = scope.columnCollection.length; i < l; i++) {
@@ -205,11 +205,10 @@
column = scope.column,
row = scope.dataRow,
format = filter('format'),
getter = parse(column.map),
childScope;
//can be useful for child directives
scope.formatedValue = format(getter(row), column.formatFunction, column.formatParameter);
scope.formatedValue = format(row[column.map], column.formatFunction, column.formatParameter);
function defaultContent() {
//clear content
@@ -267,12 +266,11 @@
replace: true,
link: function (scope, element, attrs, ctrl) {
var form = angular.element(element.children()[1]),
input = angular.element(form.children()[0]),
getter = parse(scope.column.map);
input = angular.element(form.children()[0]);
//init values
scope.isEditMode = false;
scope.value = getter(scope.row);
scope.value = scope.row[scope.column.map];
scope.submit = function () {
@@ -285,7 +283,7 @@
};
scope.toggleEditMode = function () {
scope.value = getter(scope.row);
scope.value = scope.row[scope.column.map];
scope.isEditMode = scope.isEditMode !== true;
};
@@ -595,13 +593,11 @@
*/
this.updateDataRow = function (dataRow, propertyName, newValue) {
var index = scope.displayedCollection.indexOf(dataRow),
getter = parse(propertyName),
setter = getter.assign,
oldValue;
if (index !== -1) {
oldValue = getter(scope.displayedCollection[index]);
oldValue = scope.displayedCollection[index][propertyName];
if (oldValue !== newValue) {
setter(scope.displayedCollection[index], newValue);
scope.displayedCollection[index][propertyName] = newValue;
scope.$emit('updateDataRow', {item: scope.displayedCollection[index]});
}
}

View File

@@ -243,26 +243,9 @@
return parts[0];
};
var charConversionMap = {
'__pct': /%/g,
'_': / /g,
'__qm': /\?/g,
'__brkt': /[\(\)\[\]]/g,
'__dash': /-/g,
'__amp': /&/g,
'__sl': /\//g,
'__fsl': /\\/g,
};
QueryResult.prototype.getColumnCleanName = function (column) {
var name = this.getColumnNameWithoutType(column);
if (name != '') {
_.each(charConversionMap, function(regex, replacement) {
name = name.replace(regex, replacement);
});
}
return name;
}
@@ -325,7 +308,7 @@
this.filters = filters;
}
var refreshStatus = function (queryResult, query, ttl) {
var refreshStatus = function (queryResult, query) {
Job.get({'id': queryResult.job.id}, function (response) {
queryResult.update(response);
@@ -335,7 +318,7 @@
});
} else if (queryResult.getStatus() != "failed") {
$timeout(function () {
refreshStatus(queryResult, query, ttl);
refreshStatus(queryResult, query);
}, 3000);
}
})
@@ -355,14 +338,14 @@
return this.deferred.promise;
}
QueryResult.get = function (data_source_id, query, ttl) {
QueryResult.get = function (data_source_id, query, maxAge) {
var queryResult = new QueryResult();
QueryResultResource.post({'data_source_id': data_source_id, 'query': query, 'ttl': ttl}, function (response) {
QueryResultResource.post({'data_source_id': data_source_id, 'query': query, 'max_age': maxAge}, function (response) {
queryResult.update(response);
if ('job' in response) {
refreshStatus(queryResult, query, ttl);
refreshStatus(queryResult, query);
}
});
@@ -390,7 +373,7 @@
return new Query({
query: "",
name: "New Query",
ttl: -1,
schedule: null,
user: currentUser
});
};
@@ -414,10 +397,18 @@
return '/queries/' + this.id + '/source';
};
Query.prototype.getQueryResult = function (ttl, parameters) {
if (ttl == undefined) {
ttl = this.ttl;
}
Query.prototype.hasDailySchedule = function() {
return (this.schedule && this.schedule.match(/\d\d:\d\d/) !== null);
}
Query.prototype.scheduleInLocalTime = function() {
return moment.utc(this.schedule, 'HH:mm').local().format('HH:mm');
}
Query.prototype.getQueryResult = function (maxAge, parameters) {
// if (ttl == undefined) {
// ttl = this.ttl;
// }
var queryText = this.query;
@@ -443,16 +434,16 @@
this.latest_query_data_id = null;
}
if (this.latest_query_data && ttl != 0) {
if (this.latest_query_data && maxAge != 0) {
if (!this.queryResult) {
this.queryResult = new QueryResult({'query_result': this.latest_query_data});
}
} else if (this.latest_query_data_id && ttl != 0) {
} else if (this.latest_query_data_id && maxAge != 0) {
if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.latest_query_data_id);
}
} else if (this.data_source_id) {
this.queryResult = QueryResult.get(this.data_source_id, queryText, ttl);
this.queryResult = QueryResult.get(this.data_source_id, queryText, maxAge);
}
return this.queryResult;

View File

@@ -78,15 +78,15 @@
};
} else if (columnType === 'date') {
columnDefinition.formatFunction = function (value) {
if (value) {
return value.format("DD/MM/YY");
if (value && moment.isMoment(value)) {
return value.toDate().toLocaleDateString();
}
return value;
};
} else if (columnType === 'datetime') {
columnDefinition.formatFunction = function (value) {
if (value) {
return value.format("DD/MM/YY HH:mm");
if (value && moment.isMoment(value)) {
return value.toDate().toLocaleString();
}
return value;
};

View File

@@ -37,7 +37,7 @@
<div class="panel-footer">
<span class="label label-default"
tooltip="next update {{nextUpdateTime}} (query runtime: {{queryResult.getRuntime() | durationHumanize}})"
tooltip="(query runtime: {{queryResult.getRuntime() | durationHumanize}})"
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
<span class="pull-right">

View File

@@ -92,6 +92,16 @@
<div class="row">
<div class="col-lg-3 rd-hidden-xs">
<p>
<span class="glyphicon glyphicon-user"></span>
<span class="text-muted">Created By </span>
<strong>{{query.user.name}}</strong>
</p>
<p ng-if="query.user.id != query.last_modified_by.id">
<span class="glyphicon glyphicon-user"></span>
<span class="text-muted">Last Modified By </span>
<strong>{{query.last_modified_by.name}}</strong>
</p>
<p>
<span class="glyphicon glyphicon-time"></span>
<span class="text-muted">Last update </span>
@@ -99,12 +109,6 @@
<rd-time-ago value="queryResult.query_result.retrieved_at"></rd-time-ago>
</strong>
</p>
<p>
<span class="glyphicon glyphicon-user"></span>
<span class="text-muted">Created By </span>
<strong ng-hide="isQueryOwner">{{query.user.name}}</strong>
<strong ng-show="isQueryOwner">You</strong>
</p>
<p>
<span class="glyphicon glyphicon-play"></span>
<span class="text-muted">Runtime </span>
@@ -117,8 +121,8 @@
</p>
<p>
<span class="glyphicon glyphicon-refresh"></span>
<span class="text-muted">Refresh Interval</span>
<query-refresh-select></query-refresh-select>
<span class="text-muted">Refresh Schedule</span>
<a href="" ng-click="openScheduleForm()">{{query.schedule | scheduleHumanize}}</a>
</p>
<p>

View File

@@ -0,0 +1,18 @@
<div class="modal-header">
<button type="button" class="close" aria-label="Close" ng-click="close()"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Refresh Schedule</h4>
</div>
<div class="modal-body">
<div class="radio">
<label>
<input type="radio" value="periodic" ng-model="refreshType">
<query-refresh-select ng-disabled="refreshType != 'periodic'"></query-refresh-select>
</label>
</div>
<div class="radio">
<label>
<input type="radio" value="daily" ng-model="refreshType">
<query-time-picker ng-disabled="refreshType != 'daily'"></query-time-picker>
</label>
</div>
</div>

View File

@@ -12,7 +12,6 @@
"es5-shim": "2.0.8",
"angular-moment": "0.2.0",
"moment": "2.1.0",
"angular-ui-bootstrap": "0.5.0",
"angular-ui-codemirror": "0.0.5",
"highcharts": "3.0.10",
"underscore": "1.5.1",
@@ -29,7 +28,8 @@
"angular-ui-select": "0.8.2",
"font-awesome": "~4.2.0",
"mustache": "~1.0.0",
"canvg": "gabelerner/canvg"
"canvg": "gabelerner/canvg",
"angular-ui-bootstrap-bower": "~0.12.1"
},
"devDependencies": {
"angular-mocks": "1.2.18",

View File

@@ -108,7 +108,7 @@ def status_api():
manager_status = redis_connection.hgetall('redash:status')
status['manager'] = manager_status
status['manager']['outdated_queries_count'] = models.Query.outdated_queries().count()
status['manager']['outdated_queries_count'] = len(models.Query.outdated_queries())
queues = {}
for ds in models.DataSource.select():
@@ -135,6 +135,24 @@ def format_sql_query():
return sqlparse.format(query, reindent=True, keyword_case='upper')
@app.route('/queries/new', methods=['POST'])
@auth.required
def create_query_route():
query = request.form.get('query', None)
data_source_id = request.form.get('data_source_id', None)
if query is None or data_source_id is None:
abort(400)
query = models.Query.create(name="New Query",
query=query,
data_source=data_source_id,
user=current_user._get_current_object(),
schedule=None)
return redirect('/queries/{}'.format(query.id), 303)
class BaseResource(Resource):
decorators = [auth.required]
@@ -318,7 +336,7 @@ class QueryListAPI(BaseResource):
@require_permission('create_query')
def post(self):
query_def = request.get_json(force=True)
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data']:
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'last_modified_by']:
query_def.pop(field, None)
query_def['user'] = self.current_user
@@ -326,8 +344,6 @@ class QueryListAPI(BaseResource):
query = models.Query(**query_def)
query.save()
query.create_default_visualizations()
return query.to_dict()
@require_permission('view_query')
@@ -341,7 +357,7 @@ class QueryAPI(BaseResource):
query = models.Query.get_by_id(query_id)
query_def = request.get_json(force=True)
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user']:
for field in ['id', 'created_at', 'api_key', 'visualizations', 'latest_query_data', 'user', 'last_modified_by']:
query_def.pop(field, None)
if 'latest_query_data_id' in query_def:
@@ -350,6 +366,9 @@ class QueryAPI(BaseResource):
if 'data_source_id' in query_def:
query_def['data_source'] = query_def.pop('data_source_id')
query_def['last_modified_by'] = self.current_user
# TODO: use #save() with #dirty_fields.
models.Query.update_instance(query_id, **query_def)
query = models.Query.get_by_id(query_id)
@@ -449,10 +468,12 @@ class QueryResultListAPI(BaseResource):
activity=params['query']
).save()
if params['ttl'] == 0:
max_age = int(params['max_age'])
if max_age == 0:
query_result = None
else:
query_result = models.QueryResult.get_latest(params['data_source_id'], params['query'], int(params['ttl']))
query_result = models.QueryResult.get_latest(params['data_source_id'], params['query'], max_age)
if query_result:
return {'query_result': query_result.to_dict()}

View File

@@ -28,7 +28,7 @@ class Importer(object):
def import_query(self, user, query):
new_query = self._get_or_create(models.Query, query['id'], name=query['name'],
user=user,
ttl=-1,
schedule=None,
query=query['query'],
query_hash=query['query_hash'],
description=query['description'],

View File

@@ -11,6 +11,7 @@ import peewee
from passlib.apps import custom_app_context as pwd_context
from playhouse.postgres_ext import ArrayField, DateTimeTZField, PostgresqlExtDatabase
from flask.ext.login import UserMixin, AnonymousUserMixin
import psycopg2
from redash import utils, settings
@@ -60,6 +61,30 @@ class BaseModel(peewee.Model):
def get_by_id(cls, model_id):
return cls.get(cls.id == model_id)
def pre_save(self, created):
pass
def post_save(self, created):
# Handler for post_save operations. Overriding if needed.
pass
def save(self, *args, **kwargs):
pk_value = self._get_pk_value()
created = kwargs.get('force_insert', False) or not bool(pk_value)
self.pre_save(created)
super(BaseModel, self).save(*args, **kwargs)
self.post_save(created)
class ModelTimestampsMixin(BaseModel):
updated_at = DateTimeTZField(default=datetime.datetime.now)
created_at = DateTimeTZField(default=datetime.datetime.now)
def pre_save(self, created):
super(ModelTimestampsMixin, self).pre_save(created)
self.updated_at = datetime.datetime.now()
class PermissionsCheckMixin(object):
def has_permission(self, permission):
@@ -118,7 +143,7 @@ class Group(BaseModel):
return unicode(self.id)
class User(BaseModel, UserMixin, PermissionsCheckMixin):
class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
DEFAULT_GROUPS = ['default']
id = peewee.PrimaryKeyField()
@@ -134,7 +159,9 @@ class User(BaseModel, UserMixin, PermissionsCheckMixin):
return {
'id': self.id,
'name': self.name,
'email': self.email
'email': self.email,
'updated_at': self.updated_at,
'created_at': self.created_at
}
def __init__(self, *args, **kwargs):
@@ -252,16 +279,16 @@ class QueryResult(BaseModel):
return unused_results
@classmethod
def get_latest(cls, data_source, query, ttl=0):
def get_latest(cls, data_source, query, max_age=0):
query_hash = utils.gen_query_hash(query)
if ttl == -1:
if max_age == -1:
query = cls.select().where(cls.query_hash == query_hash,
cls.data_source == data_source).order_by(cls.retrieved_at.desc())
else:
query = cls.select().where(cls.query_hash == query_hash, cls.data_source == data_source,
peewee.SQL("retrieved_at + interval '%s second' >= now() at time zone 'utc'",
ttl)).order_by(cls.retrieved_at.desc())
max_age)).order_by(cls.retrieved_at.desc())
return query.first()
@@ -288,7 +315,28 @@ class QueryResult(BaseModel):
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at)
class Query(BaseModel):
def should_schedule_next(previous_iteration, now, schedule):
if schedule.isdigit():
ttl = int(schedule)
next_iteration = previous_iteration + datetime.timedelta(seconds=ttl)
else:
hour, minute = schedule.split(':')
hour, minute = int(hour), int(minute)
# The following logic is needed for cases like the following:
# - The query scheduled to run at 23:59.
# - The scheduler wakes up at 00:01.
# - Using naive implementation of comparing timestamps, it will skip the execution.
normalized_previous_iteration = previous_iteration.replace(hour=hour, minute=minute)
if normalized_previous_iteration > previous_iteration:
previous_iteration = normalized_previous_iteration - datetime.timedelta(days=1)
next_iteration = (previous_iteration + datetime.timedelta(days=1)).replace(hour=hour, minute=minute)
return now > next_iteration
class Query(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField()
data_source = peewee.ForeignKeyField(DataSource)
latest_query_data = peewee.ForeignKeyField(QueryResult, null=True)
@@ -297,21 +345,15 @@ class Query(BaseModel):
query = peewee.TextField()
query_hash = peewee.CharField(max_length=32)
api_key = peewee.CharField(max_length=40)
ttl = peewee.IntegerField()
user_email = peewee.CharField(max_length=360, null=True)
user = peewee.ForeignKeyField(User)
last_modified_by = peewee.ForeignKeyField(User, null=True, related_name="modified_queries")
is_archived = peewee.BooleanField(default=False, index=True)
created_at = DateTimeTZField(default=datetime.datetime.now)
schedule = peewee.CharField(max_length=10, null=True)
class Meta:
db_table = 'queries'
def create_default_visualizations(self):
table_visualization = Visualization(query=self, name="Table",
description='',
type="TABLE", options="{}")
table_visualization.save()
def to_dict(self, with_stats=False, with_visualizations=False, with_user=True):
d = {
'id': self.id,
@@ -320,15 +362,17 @@ class Query(BaseModel):
'description': self.description,
'query': self.query,
'query_hash': self.query_hash,
'ttl': self.ttl,
'schedule': self.schedule,
'api_key': self.api_key,
'is_archived': self.is_archived,
'updated_at': self.updated_at,
'created_at': self.created_at,
'data_source_id': self._data.get('data_source', None)
}
if with_user:
d['user'] = self.user.to_dict()
d['last_modified_by'] = self.last_modified_by.to_dict()
else:
d['user_id'] = self._data['user']
@@ -344,7 +388,7 @@ class Query(BaseModel):
def archive(self):
self.is_archived = True
self.ttl = -1
self.schedule = None
for vis in self.visualizations:
for w in vis.widgets:
@@ -365,21 +409,19 @@ class Query(BaseModel):
@classmethod
def outdated_queries(cls):
# TODO: this will only find scheduled queries that were executed before. I think this is
# a reasonable assumption, but worth revisiting.
outdated_queries_ids = cls.select(
peewee.Func('first_value', cls.id).over(partition_by=[cls.query_hash, cls.data_source])) \
.join(QueryResult) \
.where(cls.ttl > 0,
cls.is_archived==False,
(QueryResult.retrieved_at +
(cls.ttl * peewee.SQL("interval '1 second'"))) <
peewee.SQL("(now() at time zone 'utc')"))
queries = cls.select(cls, QueryResult.retrieved_at, DataSource)\
.join(QueryResult)\
.switch(Query).join(DataSource)\
.where(cls.schedule != None)
queries = cls.select(cls, DataSource).join(DataSource) \
.where(cls.id << outdated_queries_ids)
now = datetime.datetime.utcnow().replace(tzinfo=psycopg2.tz.FixedOffsetTimezone(offset=0, name=None))
outdated_queries = {}
for query in queries:
if should_schedule_next(query.latest_query_data.retrieved_at, now, query.schedule):
key = "{}:{}".format(query.query_hash, query.data_source.id)
outdated_queries[key] = query
return queries
return outdated_queries.values()
@classmethod
def search(cls, term):
@@ -396,6 +438,7 @@ class Query(BaseModel):
@classmethod
def recent(cls, user_id):
# TODO: instead of t2 here, we should define table_alias for Query table
return cls.select().where(Event.created_at > peewee.SQL("current_date - 7")).\
join(Event, on=(Query.id == peewee.SQL("t2.object_id::integer"))).\
where(Event.action << ('edit', 'execute', 'edit_name', 'edit_description', 'view_source')).\
@@ -414,10 +457,23 @@ class Query(BaseModel):
update = cls.update(**kwargs).where(cls.id == query_id)
return update.execute()
def save(self, *args, **kwargs):
def pre_save(self, created):
super(Query, self).pre_save(created)
self.query_hash = utils.gen_query_hash(self.query)
self._set_api_key()
super(Query, self).save(*args, **kwargs)
if self.last_modified_by is None:
self.last_modified_by = self.user
def post_save(self, created):
if created:
self._create_default_visualizations()
def _create_default_visualizations(self):
table_visualization = Visualization(query=self, name="Table",
description='',
type="TABLE", options="{}")
table_visualization.save()
def _set_api_key(self):
if not self.api_key:
@@ -436,7 +492,7 @@ class Query(BaseModel):
return unicode(self.id)
class Dashboard(BaseModel):
class Dashboard(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField()
slug = peewee.CharField(max_length=140, index=True)
name = peewee.CharField(max_length=100)
@@ -445,7 +501,6 @@ class Dashboard(BaseModel):
layout = peewee.TextField()
dashboard_filters_enabled = peewee.BooleanField(default=False)
is_archived = peewee.BooleanField(default=False, index=True)
created_at = DateTimeTZField(default=datetime.datetime.now)
class Meta:
db_table = 'dashboards'
@@ -487,7 +542,9 @@ class Dashboard(BaseModel):
'user_id': self._data['user'],
'layout': layout,
'dashboard_filters_enabled': self.dashboard_filters_enabled,
'widgets': widgets_layout
'widgets': widgets_layout,
'updated_at': self.updated_at,
'created_at': self.created_at
}
@classmethod
@@ -520,7 +577,7 @@ class Dashboard(BaseModel):
return u"%s=%s" % (self.id, self.name)
class Visualization(BaseModel):
class Visualization(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField()
type = peewee.CharField(max_length=100)
query = peewee.ForeignKeyField(Query, related_name='visualizations')
@@ -538,6 +595,8 @@ class Visualization(BaseModel):
'name': self.name,
'description': self.description,
'options': json.loads(self.options),
'updated_at': self.updated_at,
'created_at': self.created_at
}
if with_query:
@@ -549,14 +608,13 @@ class Visualization(BaseModel):
return u"%s %s" % (self.id, self.type)
class Widget(BaseModel):
class Widget(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField()
visualization = peewee.ForeignKeyField(Visualization, related_name='widgets', null=True)
text = peewee.TextField(null=True)
width = peewee.IntegerField()
options = peewee.TextField()
dashboard = peewee.ForeignKeyField(Dashboard, related_name='widgets', index=True)
created_at = DateTimeTZField(default=datetime.datetime.now)
# unused; kept for backward compatability:
type = peewee.CharField(max_length=100, null=True)
@@ -571,7 +629,9 @@ class Widget(BaseModel):
'width': self.width,
'options': json.loads(self.options),
'dashboard_id': self._data['dashboard'],
'text': self.text
'text': self.text,
'updated_at': self.updated_at,
'created_at': self.created_at
}
if self.visualization and self.visualization.id:

View File

@@ -67,14 +67,14 @@ GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "")
GOOGLE_OAUTH_ENABLED = GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET
STATIC_ASSETS_PATH = fix_assets_path(os.environ.get("REDASH_STATIC_ASSETS_PATH", "../rd_ui/app/"))
JOB_EXPIRY_TIME = int(os.environ.get("REDASH_JOB_EXPIRY_TIME", 3600*6))
JOB_EXPIRY_TIME = int(os.environ.get("REDASH_JOB_EXPIRY_TIME", 3600 * 6))
COOKIE_SECRET = os.environ.get("REDASH_COOKIE_SECRET", "c292a0a3aa32397cdb050e233733900f")
LOG_LEVEL = os.environ.get("REDASH_LOG_LEVEL", "INFO")
CLIENT_SIDE_METRICS = parse_boolean(os.environ.get("REDASH_CLIENT_SIDE_METRICS", "false"))
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")
# Query Runners
QUERY_RUNNERS = [
QUERY_RUNNERS = array_from_string(os.environ.get("REDASH_ENABLED_QUERY_RUNNERS", ",".join([
'redash.query_runner.big_query',
'redash.query_runner.graphite',
'redash.query_runner.mongodb',
@@ -82,7 +82,7 @@ QUERY_RUNNERS = [
'redash.query_runner.pg',
'redash.query_runner.script',
'redash.query_runner.url',
]
])))
# Features:
FEATURE_TABLES_PERMISSIONS = parse_boolean(os.environ.get("REDASH_FEATURE_TABLES_PERMISSIONS", "false"))

View File

@@ -98,7 +98,9 @@ if [ ! -f "/opt/redash/.env" ]; then
fi
# Install latest version
REDASH_VERSION=${REDASH_VERSION-0.4.0.b589}
# REDASH_VERSION=${REDASH_VERSION-0.4.0.b589}
# modified by @fedex1 3/15/2015 seems to be the latest version at this point in time.
REDASH_VERSION=${REDASH_VERSION-0.6.0.b722}
LATEST_URL="https://github.com/EverythingMe/redash/releases/download/v${REDASH_VERSION/.b/%2Bb}/redash.$REDASH_VERSION.tar.gz"
VERSION_DIR="/opt/redash/redash.$REDASH_VERSION"
REDASH_TARBALL=/tmp/redash.tar.gz

View File

@@ -1,5 +1,6 @@
import logging
from unittest import TestCase
import datetime
from redash import settings
settings.DATABASE_CONFIG = {
'name': 'circle_test',
@@ -18,4 +19,18 @@ class BaseTestCase(TestCase):
def tearDown(self):
models.db.close_db(None)
models.create_db(False, True)
models.create_db(False, True)
def assertResponseEqual(self, expected, actual):
for k, v in expected.iteritems():
if isinstance(v, datetime.datetime) or isinstance(actual[k], datetime.datetime):
continue
if isinstance(v, list):
continue
if isinstance(v, dict):
self.assertResponseEqual(v, actual[k])
continue
self.assertEqual(v, actual[k], "{} not equal (expected: {}, actual: {}).".format(k, v, actual[k]))

View File

@@ -58,9 +58,9 @@ query_factory = ModelFactory(redash.models.Query,
name='New Query',
description='',
query='SELECT 1',
ttl=-1,
user=user_factory.create,
is_archived=False,
schedule=None,
data_source=data_source_factory.create)
query_result_factory = ModelFactory(redash.models.QueryResult,
@@ -83,4 +83,4 @@ widget_factory = ModelFactory(redash.models.Widget,
width=1,
options='{}',
dashboard=dashboard_factory.create,
visualization=visualization_factory.create)
visualization=visualization_factory.create)

View File

@@ -1,6 +1,7 @@
from contextlib import contextmanager
import json
import time
import datetime
from unittest import TestCase
from flask import url_for
from flask.ext.login import current_user
@@ -104,7 +105,11 @@ class DashboardAPITest(BaseTestCase, AuthenticationTestMixin):
with app.test_client() as c, authenticated_user(c):
rv = c.get('/api/dashboards/{0}'.format(d1.slug))
self.assertEquals(rv.status_code, 200)
self.assertDictEqual(json.loads(rv.data), d1.to_dict(with_widgets=True))
expected = d1.to_dict(with_widgets=True)
actual = json.loads(rv.data)
self.assertResponseEqual(expected, actual)
def test_get_non_existint_dashbaord(self):
with app.test_client() as c, authenticated_user(c):
@@ -222,10 +227,13 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
def test_update_query(self):
query = query_factory.create()
with app.test_client() as c, authenticated_user(c):
other_user = user_factory.create()
with app.test_client() as c, authenticated_user(c, user=other_user):
rv = json_request(c.post, '/api/queries/{0}'.format(query.id), data={'name': 'Testing'})
self.assertEqual(rv.status_code, 200)
self.assertEquals(rv.json['name'], 'Testing')
self.assertEqual(rv.json['name'], 'Testing')
self.assertEqual(rv.json['last_modified_by']['id'], other_user.id)
def test_create_query(self):
user = user_factory.create()
@@ -233,7 +241,7 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
query_data = {
'name': 'Testing',
'query': 'SELECT 1',
'ttl': 3600,
'schedule': "3600",
'data_source_id': data_source.id
}
@@ -256,9 +264,7 @@ class QueryAPITest(BaseTestCase, AuthenticationTestMixin):
rv = json_request(c.get, '/api/queries/{0}'.format(query.id))
self.assertEquals(rv.status_code, 200)
d = query.to_dict(with_visualizations=True)
d.pop('created_at')
self.assertDictContainsSubset(d, rv.json)
self.assertResponseEqual(rv.json, query.to_dict(with_visualizations=True))
def test_get_all_queries(self):
queries = [query_factory.create() for _ in range(10)]
@@ -294,7 +300,8 @@ class VisualizationAPITest(BaseTestCase):
rv = json_request(c.delete, '/api/visualizations/{0}'.format(visualization.id))
self.assertEquals(rv.status_code, 200)
self.assertEquals(models.Visualization.select().count(), 0)
# =1 because each query has a default table visualization.
self.assertEquals(models.Visualization.select().count(), 1)
def test_update_visualization(self):
visualization = visualization_factory.create()

View File

@@ -26,8 +26,10 @@ class ImportTest(BaseTestCase):
self.assertEqual(dashboard.widgets.count(),
reduce(lambda s, row: s + len(row), self.dashboard['widgets'], 0))
self.assertEqual(models.Visualization.select().count(), dashboard.widgets.count()-1)
self.assertEqual(models.Query.select().count(), dashboard.widgets.count()-2)
queries_count = models.Query.select().count()
self.assertEqual(models.Visualization.select().count(), dashboard.widgets.count()+queries_count-1)
self.assertEqual(queries_count, dashboard.widgets.count()-2)
def test_imports_updates_existing_models(self):
importer = import_export.Importer(data_source=data_source_factory.create())

View File

@@ -1,6 +1,7 @@
#encoding: utf8
import datetime
import json
from unittest import TestCase
from tests import BaseTestCase
from redash import models
from factories import dashboard_factory, query_factory, data_source_factory, query_result_factory, user_factory, widget_factory
@@ -65,20 +66,102 @@ class QueryTest(BaseTestCase):
self.assertNotIn(q1, queries)
self.assertNotIn(q2, queries)
def test_save_creates_default_visualization(self):
q = query_factory.create()
self.assertEquals(q.visualizations.count(), 1)
def test_save_updates_updated_at_field(self):
# This should be a test of ModelTimestampsMixin, but it's easier to test in context of existing model... :-\
one_day_ago = datetime.datetime.today() - datetime.timedelta(days=1)
q = query_factory.create(created_at=one_day_ago, updated_at=one_day_ago)
q.save()
self.assertNotEqual(q.updated_at, one_day_ago)
class ShouldScheduleNextTest(TestCase):
def test_interval_schedule_that_needs_reschedule(self):
now = datetime.datetime.now()
two_hours_ago = now - datetime.timedelta(hours=2)
self.assertTrue(models.should_schedule_next(two_hours_ago, now, "3600"))
def test_interval_schedule_that_doesnt_need_reschedule(self):
now = datetime.datetime.now()
half_an_hour_ago = now - datetime.timedelta(minutes=30)
self.assertFalse(models.should_schedule_next(half_an_hour_ago, now, "3600"))
def test_exact_time_that_needs_reschedule(self):
now = datetime.datetime.now()
yesterday = now - datetime.timedelta(days=1)
schedule = "{:02d}:00".format(now.hour - 3)
self.assertTrue(models.should_schedule_next(yesterday, now, schedule))
def test_exact_time_that_doesnt_need_reschedule(self):
now = datetime.datetime.now()
yesterday = (now - datetime.timedelta(days=1)).replace(hour=now.hour+3, minute=now.minute+1)
schedule = "{:02d}:00".format(now.hour + 3)
self.assertFalse(models.should_schedule_next(yesterday, now, schedule))
def test_exact_time_with_day_change(self):
now = datetime.datetime.now().replace(hour=0, minute=1)
previous = (now - datetime.timedelta(days=2)).replace(hour=23, minute=59)
schedule = "23:59".format(now.hour + 3)
self.assertTrue(models.should_schedule_next(previous, now, schedule))
class QueryOutdatedQueriesTest(BaseTestCase):
# TODO: this test can be refactored to use mock version of should_schedule_next to simplify it.
def test_outdated_queries_skips_unscheduled_queries(self):
query = query_factory.create(schedule=None)
queries = models.Query.outdated_queries()
self.assertNotIn(query, queries)
def test_outdated_queries_works_with_ttl_based_schedule(self):
two_hours_ago = datetime.datetime.now() - datetime.timedelta(hours=2)
query = query_factory.create(schedule="3600")
query_result = query_result_factory.create(query=query, retrieved_at=two_hours_ago)
query.latest_query_data = query_result
query.save()
queries = models.Query.outdated_queries()
self.assertIn(query, queries)
def test_skips_fresh_queries(self):
half_an_hour_ago = datetime.datetime.now() - datetime.timedelta(minutes=30)
query = query_factory.create(schedule="3600")
query_result = query_result_factory.create(query=query, retrieved_at=half_an_hour_ago)
query.latest_query_data = query_result
query.save()
queries = models.Query.outdated_queries()
self.assertNotIn(query, queries)
def test_outdated_queries_works_with_specific_time_schedule(self):
half_an_hour_ago = datetime.datetime.utcnow() - datetime.timedelta(minutes=30)
query = query_factory.create(schedule=half_an_hour_ago.strftime('%H:%M'))
query_result = query_result_factory.create(query=query, retrieved_at=half_an_hour_ago-datetime.timedelta(days=1))
query.latest_query_data = query_result
query.save()
queries = models.Query.outdated_queries()
self.assertIn(query, queries)
class QueryArchiveTest(BaseTestCase):
def setUp(self):
super(QueryArchiveTest, self).setUp()
def test_archive_query_sets_flag(self):
query = query_factory.create(ttl=1)
query = query_factory.create()
query.archive()
query = models.Query.get_by_id(query.id)
self.assertEquals(query.is_archived, True)
def test_archived_query_doesnt_return_in_all(self):
query = query_factory.create(ttl=1)
query = query_factory.create(schedule="1")
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
query_result = models.QueryResult.store_result(query.data_source.id, query.query_hash, query.query, "1",
123, yesterday)
@@ -103,13 +186,13 @@ class QueryArchiveTest(BaseTestCase):
self.assertRaises(models.Widget.DoesNotExist, models.Widget.get_by_id, widget.id)
def test_removes_scheduling(self):
query = query_factory.create(ttl=1)
query = query_factory.create(schedule="1")
query.archive()
query = models.Query.get_by_id(query.id)
self.assertEqual(-1, query.ttl)
self.assertEqual(None, query.schedule)
@@ -145,7 +228,7 @@ class QueryResultTest(BaseTestCase):
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
qr = query_result_factory.create(retrieved_at=yesterday)
found_query_result = models.QueryResult.get_latest(qr.data_source, qr.query, ttl=60)
found_query_result = models.QueryResult.get_latest(qr.data_source, qr.query, max_age=60)
self.assertIsNone(found_query_result)
@@ -153,7 +236,7 @@ class QueryResultTest(BaseTestCase):
yesterday = datetime.datetime.now() - datetime.timedelta(seconds=30)
qr = query_result_factory.create(retrieved_at=yesterday)
found_query_result = models.QueryResult.get_latest(qr.data_source, qr.query, ttl=120)
found_query_result = models.QueryResult.get_latest(qr.data_source, qr.query, max_age=120)
self.assertEqual(found_query_result, qr)

View File

@@ -10,7 +10,7 @@ from redash.tasks import refresh_queries
# 2. test for the refresh_query task
class TestRefreshQueries(BaseTestCase):
def test_enqueues_outdated_queries(self):
query = query_factory.create(ttl=60)
query = query_factory.create(schedule="60")
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
query_hash=query.query_hash)
@@ -22,7 +22,7 @@ class TestRefreshQueries(BaseTestCase):
add_job_mock.assert_called_with(query.query, query.data_source, scheduled=True)
def test_skips_fresh_queries(self):
query = query_factory.create(ttl=1200)
query = query_factory.create(schedule="1200")
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
query_hash=query.query_hash)
@@ -32,7 +32,7 @@ class TestRefreshQueries(BaseTestCase):
self.assertFalse(add_job_mock.called)
def test_skips_queries_with_no_ttl(self):
query = query_factory.create(ttl=-1)
query = query_factory.create(schedule=None)
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
query_hash=query.query_hash)
@@ -42,8 +42,8 @@ class TestRefreshQueries(BaseTestCase):
self.assertFalse(add_job_mock.called)
def test_enqueues_query_only_once(self):
query = query_factory.create(ttl=60)
query2 = query_factory.create(ttl=60, query=query.query, query_hash=query.query_hash,
query = query_factory.create(schedule="60")
query2 = query_factory.create(schedule="60", query=query.query, query_hash=query.query_hash,
data_source=query.data_source)
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
@@ -58,8 +58,8 @@ class TestRefreshQueries(BaseTestCase):
add_job_mock.assert_called_once_with(query.query, query.data_source, scheduled=True)
def test_enqueues_query_with_correct_data_source(self):
query = query_factory.create(ttl=60)
query2 = query_factory.create(ttl=60, query=query.query, query_hash=query.query_hash)
query = query_factory.create(schedule="60")
query2 = query_factory.create(schedule="60", query=query.query, query_hash=query.query_hash)
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
query_hash=query.query_hash)
@@ -74,9 +74,10 @@ class TestRefreshQueries(BaseTestCase):
self.assertEquals(2, add_job_mock.call_count)
def test_enqueues_only_for_relevant_data_source(self):
query = query_factory.create(ttl=60)
query2 = query_factory.create(ttl=3600, query=query.query, query_hash=query.query_hash)
retrieved_at = datetime.datetime.utcnow() - datetime.timedelta(minutes=10)
query = query_factory.create(schedule="60")
query2 = query_factory.create(schedule="3600", query=query.query, query_hash=query.query_hash)
import psycopg2
retrieved_at = datetime.datetime.utcnow().replace(tzinfo=psycopg2.tz.FixedOffsetTimezone(offset=0, name=None)) - datetime.timedelta(minutes=10)
query_result = query_result_factory.create(retrieved_at=retrieved_at, query=query.query,
query_hash=query.query_hash)
query.latest_query_data = query_result