diff --git a/client/app/components/queries/schedule-dialog.html b/client/app/components/queries/schedule-dialog.html
index 8f1ab21541..f9344238a1 100644
--- a/client/app/components/queries/schedule-dialog.html
+++ b/client/app/components/queries/schedule-dialog.html
@@ -15,4 +15,8 @@
Refresh Schedule
+
diff --git a/client/app/components/queries/schedule-dialog.js b/client/app/components/queries/schedule-dialog.js
index 1fc60c3925..db6ebe0320 100644
--- a/client/app/components/queries/schedule-dialog.js
+++ b/client/app/components/queries/schedule-dialog.js
@@ -103,6 +103,17 @@ function queryRefreshSelect(clientConfig, Policy) {
};
}
+function scheduleUntil() {
+ return {
+ restrict: 'E',
+ scope: {
+ query: '=',
+ saveQuery: '=',
+ },
+ template: '',
+ };
+}
+
const ScheduleForm = {
controller() {
this.query = this.resolve.query;
@@ -125,5 +136,6 @@ const ScheduleForm = {
export default function init(ngModule) {
ngModule.directive('queryTimePicker', queryTimePicker);
ngModule.directive('queryRefreshSelect', queryRefreshSelect);
+ ngModule.directive('scheduleUntil', scheduleUntil);
ngModule.component('scheduleDialog', ScheduleForm);
}
diff --git a/client/app/services/query.js b/client/app/services/query.js
index 439742ee63..39b423839e 100644
--- a/client/app/services/query.js
+++ b/client/app/services/query.js
@@ -402,6 +402,10 @@ function QueryResource(
.format('HH:mm');
};
+ Query.prototype.hasScheduleExpiry = function hasScheduleExpiry() {
+ return (this.schedule && this.schedule_until);
+ };
+
Query.prototype.hasResult = function hasResult() {
return !!(this.latest_query_data || this.latest_query_data_id);
};
diff --git a/migrations/versions/eb2f788f997e_.py b/migrations/versions/eb2f788f997e_.py
new file mode 100644
index 0000000000..71fd2bd5b3
--- /dev/null
+++ b/migrations/versions/eb2f788f997e_.py
@@ -0,0 +1,27 @@
+"""Add 'schedule_until' column to queries.
+
+Revision ID: eb2f788f997e
+Revises: d1eae8b9893e
+Create Date: 2017-03-02 12:20:00.029066
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'eb2f788f997e'
+down_revision = 'd1eae8b9893e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.add_column(
+ 'queries',
+ sa.Column('schedule_until', sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('queries', 'schedule_until')
diff --git a/redash/handlers/queries.py b/redash/handlers/queries.py
index e2fd67a07b..673cb69682 100644
--- a/redash/handlers/queries.py
+++ b/redash/handlers/queries.py
@@ -103,6 +103,7 @@ def post(self):
:json string query: Query text
:>json string query_hash: Hash of query text
:>json string schedule: Schedule interval, in seconds, for repeated execution of this query
+ :json string api_key: Key for public access to this query's results.
:>json boolean is_archived: Whether this query is displayed in indexes and search results or not.
:>json boolean is_draft: Whether this query is a draft or not
diff --git a/redash/models.py b/redash/models.py
index b4449472bb..32c0a6c016 100644
--- a/redash/models.py
+++ b/redash/models.py
@@ -881,6 +881,7 @@ class Query(ChangeTrackingMixin, TimestampMixin, BelongsToOrgMixin, db.Model):
is_draft = Column(db.Boolean, default=True, index=True)
schedule = Column(db.String(10), nullable=True)
schedule_failures = Column(db.Integer, default=0)
+ schedule_until = Column(db.DateTime(True), nullable=True)
visualizations = db.relationship("Visualization", cascade="all, delete-orphan")
options = Column(MutableDict.as_mutable(PseudoJSON), default={})
search_vector = Column(TSVectorType('id', 'name', 'description', 'query',
@@ -1001,7 +1002,9 @@ def by_user(cls, user):
def outdated_queries(cls):
queries = (db.session.query(Query)
.options(joinedload(Query.latest_query_data).load_only('retrieved_at'))
- .filter(Query.schedule != None)
+ .filter(Query.schedule != None,
+ (Query.schedule_until == None) |
+ (Query.schedule_until > db.func.now()))
.order_by(Query.id))
now = utils.utcnow()
diff --git a/redash/serializers.py b/redash/serializers.py
index f1e40de803..641c39ce43 100644
--- a/redash/serializers.py
+++ b/redash/serializers.py
@@ -90,6 +90,7 @@ def serialize_query(query, with_stats=False, with_visualizations=False, with_use
'query': query.query_text,
'query_hash': query.query_hash,
'schedule': query.schedule,
+ 'schedule_until': query.schedule_until,
'api_key': query.api_key,
'is_archived': query.is_archived,
'is_draft': query.is_draft,
diff --git a/tests/test_models.py b/tests/test_models.py
index f08e28ec53..7e60e510ab 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -192,6 +192,34 @@ def test_failure_extends_schedule(self):
query_result.retrieved_at = utcnow() - datetime.timedelta(minutes=17)
self.assertEqual(list(models.Query.outdated_queries()), [query])
+ def test_schedule_until_after(self):
+ """
+ Queries with non-null ``schedule_until`` are not reported by
+ Query.outdated_queries() after the given time is past.
+ """
+ three_hours_ago = utcnow() - datetime.timedelta(hours=3)
+ two_hours_ago = utcnow() - datetime.timedelta(hours=2)
+ query = self.factory.create_query(schedule="3600", schedule_until=three_hours_ago)
+ query_result = self.factory.create_query_result(query=query.query_text, retrieved_at=two_hours_ago)
+ query.latest_query_data = query_result
+
+ queries = models.Query.outdated_queries()
+ self.assertNotIn(query, queries)
+
+ def test_schedule_until_before(self):
+ """
+ Queries with non-null ``schedule_until`` are reported by
+ Query.outdated_queries() before the given time is past.
+ """
+ one_hour_from_now = utcnow() + datetime.timedelta(hours=1)
+ two_hours_ago = utcnow() - datetime.timedelta(hours=2)
+ query = self.factory.create_query(schedule="3600", schedule_until=one_hour_from_now)
+ query_result = self.factory.create_query_result(query=query.query_text, retrieved_at=two_hours_ago)
+ query.latest_query_data = query_result
+
+ queries = models.Query.outdated_queries()
+ self.assertIn(query, queries)
+
class QueryArchiveTest(BaseTestCase):
def setUp(self):