Repository: getredash/redash

Issue:
Forked queries cannot be scheduled to recur
As described here: https://github.com/getredash/redash/pull/2426#issuecomment-454827177

This is due to forked queries having a `None` value for `schedule` but the UI expects the schedule format to be like 
```
{
  'interval': ...,
  'until': ...,
  'day_of_week': ...,
  'time': ...
}
```


Discussion:

Resolving patch:
diff --git a/migrations/versions/73beceabb948_bring_back_null_schedule.py b/migrations/versions/73beceabb948_bring_back_null_schedule.py
new file mode 100644
--- /dev/null
+++ b/migrations/versions/73beceabb948_bring_back_null_schedule.py
@@ -0,0 +1,56 @@
+"""bring_back_null_schedule
+
+Revision ID: 73beceabb948
+Revises: e7f8a917aa8e
+Create Date: 2019-01-17 13:22:21.729334
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+from sqlalchemy.sql import table
+
+from redash.models import MutableDict, PseudoJSON
+
+# revision identifiers, used by Alembic.
+revision = '73beceabb948'
+down_revision = 'e7f8a917aa8e'
+branch_labels = None
+depends_on = None
+
+
+def is_empty_schedule(schedule):
+    if schedule is None:
+        return False
+
+    if schedule == {}:
+        return True
+
+    if schedule.get('interval') is None and schedule.get('until') is None and schedule.get('day_of_week') is None and schedule.get('time') is None:
+        return True
+
+    return False
+
+
+def upgrade():
+    op.alter_column('queries', 'schedule',
+                    nullable=True,
+                    server_default=None)
+
+    queries = table(
+        'queries',
+        sa.Column('id', sa.Integer, primary_key=True),
+        sa.Column('schedule', MutableDict.as_mutable(PseudoJSON)))
+
+    conn = op.get_bind()
+    for query in conn.execute(queries.select()):
+        if is_empty_schedule(query.schedule):
+            conn.execute(
+                queries
+                .update()
+                .where(queries.c.id == query.id)
+                .values(schedule=None))
+
+
+def downgrade():
+    pass
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -443,7 +443,7 @@ def __str__(self):
     def archive(self, user=None):
         db.session.add(self)
         self.is_archived = True
-        self.schedule = {}
+        self.schedule = None
 
         for vis in self.visualizations:
             for w in vis.widgets:
@@ -550,11 +550,11 @@ def by_user(cls, user):
 
     @classmethod
     def outdated_queries(cls):
-        queries = (db.session.query(Query)
-                   .options(joinedload(Query.latest_query_data).load_only('retrieved_at'))
-                   .filter(Query.schedule != {})
-                   .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()
diff --git a/redash/models/types.py b/redash/models/types.py
--- a/redash/models/types.py
+++ b/redash/models/types.py
@@ -24,6 +24,9 @@ class PseudoJSON(TypeDecorator):
     impl = db.Text
 
     def process_bind_param(self, value, dialect):
+        if value is None:
+            return value
+
         return json_dumps(value)
 
     def process_result_value(self, value, dialect):


Test patch:
diff --git a/client/app/components/queries/ScheduleDialog.test.js b/client/app/components/queries/ScheduleDialog.test.js
--- a/client/app/components/queries/ScheduleDialog.test.js
+++ b/client/app/components/queries/ScheduleDialog.test.js
@@ -1,17 +1,11 @@
 import React from 'react';
 import { mount } from 'enzyme';
 import { ScheduleDialog } from './ScheduleDialog';
+import RefreshScheduleDefault from '../proptypes';
 
 const defaultProps = {
   show: true,
-  query: {
-    schedule: {
-      time: null,
-      until: null,
-      interval: null,
-      day_of_week: null,
-    },
-  },
+  schedule: RefreshScheduleDefault,
   refreshOptions: [
     60, 300, 600, // 1, 5 ,10 mins
     3600, 36000, 82800, // 1, 10, 23 hours
@@ -23,12 +17,11 @@ const defaultProps = {
 };
 
 function getWrapper(schedule = {}, props = {}) {
-  const defaultSchedule = defaultProps.query.schedule;
   props = Object.assign(
     {},
     defaultProps,
     props,
-    { query: { schedule: Object.assign({}, defaultSchedule, schedule) } },
+    { schedule: Object.assign({}, RefreshScheduleDefault, schedule) },
   );
   return [mount(<ScheduleDialog {...props} />), props];
 }
@@ -78,7 +71,7 @@ describe('ScheduleDialog', () => {
       const [wrapper] = getWrapper({
         interval: 1209600,
         time: '22:15',
-        day_of_week: 2,
+        day_of_week: 'Monday',
       });
 
       test('Sets to correct interval', () => {
diff --git a/client/app/components/queries/__snapshots__/ScheduleDialog.test.js.snap b/client/app/components/queries/__snapshots__/ScheduleDialog.test.js.snap
--- a/client/app/components/queries/__snapshots__/ScheduleDialog.test.js.snap
+++ b/client/app/components/queries/__snapshots__/ScheduleDialog.test.js.snap
@@ -1632,6 +1632,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
 >
   <RadioGroup
     buttonStyle="outline"
+    defaultValue="Mon"
     disabled={false}
     onChange={[Function]}
     size="medium"
@@ -1700,7 +1701,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
         value="Mon"
       >
         <Radio
-          checked={false}
+          checked={true}
           className="input"
           disabled={false}
           onChange={[Function]}
@@ -1709,10 +1710,10 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
           value="Mon"
         >
           <label
-            className="input ant-radio-button-wrapper"
+            className="input ant-radio-button-wrapper ant-radio-button-wrapper-checked"
           >
             <Checkbox
-              checked={false}
+              checked={true}
               className=""
               defaultChecked={false}
               disabled={false}
@@ -1725,11 +1726,11 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "2 Weeks 22:15 Tu
               value="Mon"
             >
               <span
-                className="ant-radio-button"
+                className="ant-radio-button ant-radio-button-checked"
                 style={Object {}}
               >
                 <input
-                  checked={false}
+                  checked={true}
                   className="ant-radio-button-input"
                   disabled={false}
                   onBlur={[Function]}
@@ -2375,7 +2376,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
     onChange={[Function]}
     showSearch={false}
     transitionName="slide-up"
-    value={null}
   >
     <Select
       allowClear={false}
@@ -2445,7 +2445,6 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
       tags={false}
       tokenSeparators={Array []}
       transitionName="slide-up"
-      value={null}
     >
       <SelectTrigger
         ariaId="test-uuid"
@@ -2478,11 +2477,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
         }
         showSearch={false}
         transitionName="slide-up"
-        value={
-          Array [
-            null,
-          ]
-        }
+        value={Array []}
         visible={false}
       >
         <Trigger
@@ -2577,11 +2572,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
               onMenuSelect={[Function]}
               onPopupFocus={[Function]}
               prefixCls="ant-select-dropdown"
-              value={
-                Array [
-                  null,
-                ]
-              }
+              value={Array []}
               visible={false}
             />
           }
@@ -2599,11 +2590,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
           }
           showSearch={false}
           transitionName="slide-up"
-          value={
-            Array [
-              null,
-            ]
-          }
+          value={Array []}
           visible={false}
         >
           <div
@@ -2631,21 +2618,7 @@ exports[`ScheduleDialog Sets correct schedule settings Sets to "Never" 1`] = `
             >
               <div
                 className="ant-select-selection__rendered"
-              >
-                <div
-                  className="ant-select-selection-selected-value"
-                  key="value"
-                  style={
-                    Object {
-                      "display": "block",
-                      "opacity": 1,
-                    }
-                  }
-                  title="Never"
-                >
-                  Never
-                </div>
-              </div>
+              />
               <span
                 className="ant-select-arrow"
                 key="arrow"
diff --git a/tests/factories.py b/tests/factories.py
--- a/tests/factories.py
+++ b/tests/factories.py
@@ -75,7 +75,7 @@ def __call__(self):
                              user=user_factory.create,
                              is_archived=False,
                              is_draft=False,
-                             schedule={},
+                             schedule=None,
                              data_source=data_source_factory.create,
                              org_id=1)
 
diff --git a/tests/models/test_dashboards.py b/tests/models/test_dashboards.py
--- a/tests/models/test_dashboards.py
+++ b/tests/models/test_dashboards.py
@@ -20,11 +20,11 @@ def create_tagged_dashboard(self, tags):
         return dashboard
 
     def test_all_tags(self):
-        self.create_tagged_dashboard(tags=['tag1'])
-        self.create_tagged_dashboard(tags=['tag1', 'tag2'])
-        self.create_tagged_dashboard(tags=['tag1', 'tag2', 'tag3'])
+        self.create_tagged_dashboard(tags=[u'tag1'])
+        self.create_tagged_dashboard(tags=[u'tag1', u'tag2'])
+        self.create_tagged_dashboard(tags=[u'tag1', u'tag2', u'tag3'])
 
         self.assertEqual(
             list(Dashboard.all_tags(self.factory.org, self.factory.user)),
-            [('tag1', 3), ('tag2', 2), ('tag3', 1)]
+            [(u'tag1', 3), (u'tag2', 2), (u'tag3', 1)]
         )
diff --git a/tests/test_models.py b/tests/test_models.py
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -136,9 +136,12 @@ 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 = self.factory.create_query(schedule={'interval':None, 'time': None, 'until':None, 'day_of_week':None})
+        query_with_none = self.factory.create_query(schedule=None)
+
         queries = models.Query.outdated_queries()
 
         self.assertNotIn(query, queries)
+        self.assertNotIn(query_with_none, queries)
 
     def test_outdated_queries_works_with_ttl_based_schedule(self):
         two_hours_ago = utcnow() - datetime.timedelta(hours=2)
@@ -318,7 +321,7 @@ def test_removes_scheduling(self):
 
         query.archive()
 
-        self.assertEqual({}, query.schedule)
+        self.assertIsNone(query.schedule)
 
     def test_deletes_alerts(self):
         subscription = self.factory.create_alert_subscription()