Repository: biolab/orange3

Issue:
Select Rows: calendar view for TimeVariable
**Is your feature request related to a problem? Please describe.**

When setting date ranges with Select Rows, one has to type literal date into line edit (e.g. 2020-02-02).

**Describe the solution you'd like**

It would be nice to see calendar view similar to the one in Time Slice for TimeVariable.



Discussion:

Resolving patch:
diff --git a/Orange/widgets/data/owselectrows.py b/Orange/widgets/data/owselectrows.py
--- a/Orange/widgets/data/owselectrows.py
+++ b/Orange/widgets/data/owselectrows.py
@@ -1,25 +1,22 @@
 import enum
 from collections import OrderedDict
+from datetime import datetime, timezone, timedelta
 
 import numpy as np
 
 from AnyQt.QtWidgets import (
     QWidget, QTableWidget, QHeaderView, QComboBox, QLineEdit, QToolButton,
     QMessageBox, QMenu, QListView, QGridLayout, QPushButton, QSizePolicy,
-    QLabel, QHBoxLayout)
-from AnyQt.QtGui import (
-    QDoubleValidator, QRegExpValidator, QStandardItemModel, QStandardItem,
-    QFontMetrics, QPalette
-)
-from AnyQt.QtCore import Qt, QPoint, QRegExp, QPersistentModelIndex, QLocale
+    QLabel, QHBoxLayout, QDateTimeEdit, QCalendarWidget)
+from AnyQt.QtGui import (QDoubleValidator, QStandardItemModel, QStandardItem,
+                         QFontMetrics, QPalette)
+from AnyQt.QtCore import Qt, QPoint, QPersistentModelIndex, QLocale, \
+    QDateTime, QDate, QTime
 
-from Orange.widgets.utils.itemmodels import DomainModel
 from orangewidget.utils.combobox import ComboBoxSearch
-
+from Orange.widgets.utils.itemmodels import DomainModel
 from Orange.data import (
-    ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable,
-    Table
-)
+    ContinuousVariable, DiscreteVariable, StringVariable, TimeVariable, Table)
 import Orange.data.filter as data_filter
 from Orange.data.filter import FilterContinuous, FilterString
 from Orange.data.sql.table import SqlTable
@@ -39,6 +36,7 @@
 class SelectRowsContextHandler(DomainContextHandler):
     """Context handler that filters conditions"""
 
+    # pylint: disable=arguments-differ
     def is_valid_item(self, setting, condition, attrs, metas):
         """Return True if condition applies to a variable in given domain."""
         varname, *_ = condition
@@ -57,6 +55,7 @@ def encode_setting(self, context, setting, value):
                 # None will match the value returned by all_vars.get
                 encoded.append((attr, None, op, values))
             else:
+                # check for exact match, pylint: disable=unidiomatic-typecheck
                 if type(attr) is ContinuousVariable \
                         and values and isinstance(values[0], str):
                     values = [QLocale().toDouble(v)[0] for v in values]
@@ -71,6 +70,7 @@ def encode_setting(self, context, setting, value):
                 ))
         return encoded
 
+    # pylint: disable=arguments-differ
     def decode_setting(self, setting, value, domain=None, *_args):
         value = super().decode_setting(setting, value, domain)
         if setting.name == 'conditions':
@@ -82,6 +82,7 @@ def decode_setting(self, setting, value, domain=None, *_args):
                 if tpe != [None] \
                         or not tpe and attr not in OWSelectRows.AllTypes:
                     attr = domain[attr]
+                # check for exact match, pylint: disable=unidiomatic-typecheck
                 if type(attr) is ContinuousVariable \
                         or OWSelectRows.AllTypes.get(attr) == CONTINUOUS:
                     values = [QLocale().toString(float(i), 'f') for i in values]
@@ -137,6 +138,7 @@ def filter_value(self, setting, data, domain, attrs, metas):
 
 
 class FilterDiscreteType(enum.Enum):
+    # pylint: disable=invalid-name
     Equal = "Equal"
     NotEqual = "NotEqual"
     In = "In"
@@ -150,6 +152,31 @@ def _plural(s):
     return s
 
 
+class CalendarWidgetWithTime(QCalendarWidget):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.timeedit = QDateTimeEdit(displayFormat="hh:mm:ss")
+        self.timeedit.setTime(self.parent().min_datetime.time())
+
+        self._time_layout = sublay = QHBoxLayout()
+        sublay.setContentsMargins(6, 6, 6, 6)
+        sublay.addStretch(1)
+        sublay.addWidget(QLabel("Time: "))
+        sublay.addWidget(self.timeedit)
+        sublay.addStretch(1)
+        self.layout().addLayout(sublay)
+
+    def minimumSize(self):
+        return self.sizeHint()
+
+    def sizeHint(self):
+        size = super().sizeHint()
+        size.setHeight(
+            size.height()
+            + self._time_layout.sizeHint().height()
+            + self.layout().spacing())
+        return size
+
 class OWSelectRows(widget.OWWidget):
     name = "Select Rows"
     id = "Orange.widgets.data.file"
@@ -306,7 +333,6 @@ def add_row(self, attr=None, condition_type=None, condition_value=None):
         attr_combo.row = row
         attr_combo.setCurrentIndex(self.variable_model.indexOf(attr) if attr
                                    else len(self.AllTypes) + 1)
-
         self.cond_list.setCellWidget(row, 0, attr_combo)
 
         index = QPersistentModelIndex(model.index(row, 3))
@@ -354,9 +380,9 @@ def remove_all_rows(self):
         # Disconnect signals to avoid stray emits when changing variable_model
         for row in range(self.cond_list.rowCount()):
             for col in (0, 1):
-                widget = self.cond_list.cellWidget(row, col)
-                if widget:
-                    widget.currentIndexChanged.disconnect()
+                widg = self.cond_list.cellWidget(row, col)
+                if widg:
+                    widg.currentIndexChanged.disconnect()
         self.cond_list.clear()
         self.cond_list.setRowCount(0)
         self.remove_all_button.setDisabled(True)
@@ -386,8 +412,18 @@ def set_new_operators(self, attr_combo, adding_all,
 
     @staticmethod
     def _get_lineedit_contents(box):
-        return [child.text() for child in getattr(box, "controls", [box])
-                if isinstance(child, QLineEdit)]
+        contents = []
+        for child in getattr(box, "controls", [box]):
+            if isinstance(child, QLineEdit):
+                contents.append(child.text())
+            elif isinstance(child, DateTimeWidget):
+                if child.format == (0, 1):
+                    contents.append(child.time())
+                elif child.format == (1, 0):
+                    contents.append(child.date())
+                elif child.format == (1, 1):
+                    contents.append(child.dateTime())
+        return contents
 
     @staticmethod
     def _get_value_contents(box):
@@ -408,6 +444,13 @@ def _get_value_contents(box):
                             names.append(item.text())
                     child.desc_text = ', '.join(names)
                     child.set_text()
+            elif isinstance(child, DateTimeWidget):
+                if child.format == (0, 1):
+                    cont.append(child.time())
+                elif child.format == (1, 0):
+                    cont.append(child.date())
+                elif child.format == (1, 1):
+                    cont.append(child.dateTime())
             elif isinstance(child, QLabel) or child is None:
                 pass
             else:
@@ -443,11 +486,6 @@ def add_numeric(contents):
             le.setValidator(OWSelectRows.QDoubleValidatorEmpty())
             return le
 
-        def add_datetime(contents):
-            le = add_textual(contents)
-            le.setValidator(QRegExpValidator(QRegExp(TimeVariable.REGEX)))
-            return le
-
         box = self.cond_list.cellWidget(oper_combo.row, 2)
         lc = ["", ""]
         oper = oper_combo.currentIndex()
@@ -457,10 +495,11 @@ def add_datetime(contents):
             var = None
         else:
             var = self.data.domain[attr_name]
+            var_idx = self.data.domain.index(attr_name)
             vtype = vartype(var)
             if selected_values is not None:
                 lc = list(selected_values) + ["", ""]
-                lc = [str(x) for x in lc[:2]]
+                lc = [str(x) if vtype != 4 else x for x in lc[:2]]
         if box and vtype == box.var_type:
             lc = self._get_lineedit_contents(box) + lc
 
@@ -471,7 +510,7 @@ def add_datetime(contents):
         elif var is not None and var.is_discrete:
             if oper_combo.currentText().endswith(" one of"):
                 if selected_values:
-                    lc = [x for x in list(selected_values)]
+                    lc = list(selected_values)
                 button = DropDownToolButton(self, var, lc)
                 button.var_type = vtype
                 self.cond_list.setCellWidget(oper_combo.row, 2, button)
@@ -489,17 +528,48 @@ def add_datetime(contents):
             box = gui.hBox(self, addToLayout=False)
             box.var_type = vtype
             self.cond_list.setCellWidget(oper_combo.row, 2, box)
-            if vtype in (2, 4):  # continuous, time:
-                validator = add_datetime if isinstance(var, TimeVariable) else add_numeric
-                box.controls = [validator(lc[0])]
+            if vtype == 2:  # continuous:
+                box.controls = [add_numeric(lc[0])]
                 if oper > 5:
                     gui.widgetLabel(box, " and ")
-                    box.controls.append(validator(lc[1]))
+                    box.controls.append(add_numeric(lc[1]))
             elif vtype == 3:  # string:
                 box.controls = [add_textual(lc[0])]
                 if oper in [6, 7]:
                     gui.widgetLabel(box, " and ")
                     box.controls.append(add_textual(lc[1]))
+            elif vtype == 4:  # time:
+                def invalidate_datetime():
+                    if w_:
+                        if w.dateTime() > w_.dateTime():
+                            w_.setDateTime(w.dateTime())
+                        if w.format == (1, 1):
+                            w.calendarWidget.timeedit.setTime(w.time())
+                            w_.calendarWidget.timeedit.setTime(w_.time())
+                    elif w.format == (1, 1):
+                        w.calendarWidget.timeedit.setTime(w.time())
+
+                def datetime_changed():
+                    self.conditions_changed()
+                    invalidate_datetime()
+
+                datetime_format = (var.have_date, var.have_time)
+                column = self.data[:, var_idx]
+                w = DateTimeWidget(self, column, datetime_format)
+                w.set_datetime(lc[0])
+                box.controls = [w]
+                box.layout().addWidget(w)
+                w.dateTimeChanged.connect(datetime_changed)
+                if oper > 5:
+                    gui.widgetLabel(box, " and ")
+                    w_ = DateTimeWidget(self, column, datetime_format)
+                    w_.set_datetime(lc[1])
+                    box.layout().addWidget(w_)
+                    box.controls.append(w_)
+                    invalidate_datetime()
+                    w_.dateTimeChanged.connect(datetime_changed)
+                else:
+                    w_ = None
             else:
                 box.controls = []
         if not adding_all:
@@ -557,12 +627,14 @@ def conditions_changed(self):
             # controls are being constructed
             pass
 
-    def _values_to_floats(self, attr, values):
-        if not len(values):
+    @staticmethod
+    def _values_to_floats(attr, values):
+        if len(values) == 0:
             return values
         if not all(values):
             return None
         if isinstance(attr, TimeVariable):
+            values = (value.toString(format=Qt.ISODate) for value in values)
             parse = lambda x: (attr.parse(x), True)
         else:
             parse = QLocale().toDouble
@@ -598,7 +670,7 @@ def commit(self):
                     operators = self.Operators[type(attr)]
                 opertype, _ = operators[oper_idx]
                 if attr_type == 0:
-                    filter = data_filter.IsDefined()
+                    filt = data_filter.IsDefined()
                 elif attr_type in (2, 4):  # continuous, time
                     try:
                         floats = self._values_to_floats(attr, values)
@@ -607,10 +679,10 @@ def commit(self):
                         return
                     if floats is None:
                         continue
-                    filter = data_filter.FilterContinuous(
+                    filt = data_filter.FilterContinuous(
                         attr_index, opertype, *floats)
                 elif attr_type == 3:  # string
-                    filter = data_filter.FilterString(
+                    filt = data_filter.FilterString(
                         attr_index, opertype, *[str(v) for v in values])
                 else:
                     if opertype == FilterDiscreteType.IsDefined:
@@ -628,14 +700,14 @@ def commit(self):
                             f_values = set(values)
                         else:
                             raise ValueError("invalid operand")
-                    filter = data_filter.FilterDiscrete(attr_index, f_values)
-                conditions.append(filter)
+                    filt = data_filter.FilterDiscrete(attr_index, f_values)
+                conditions.append(filt)
 
             if conditions:
-                self.filters = data_filter.Values(conditions)
-                matching_output = self.filters(self.data)
-                self.filters.negate = True
-                non_matching_output = self.filters(self.data)
+                filters = data_filter.Values(conditions)
+                matching_output = filters(self.data)
+                filters.negate = True
+                non_matching_output = filters(self.data)
 
                 row_sel = np.in1d(self.data.ids, matching_output.ids)
                 annotated_output = create_annotated_table(self.data, row_sel)
@@ -659,11 +731,11 @@ def commit(self):
                 non_matching_output = remover(non_matching_output)
                 annotated_output = remover(annotated_output)
 
-        if matching_output is not None and not len(matching_output):
+        if not matching_output:
             matching_output = None
-        if non_matching_output is not None and not len(non_matching_output):
+        if not non_matching_output:
             non_matching_output = None
-        if annotated_output is not None and not len(annotated_output):
+        if not annotated_output:
             annotated_output = None
 
         self.Outputs.matching_data.send(matching_output)
@@ -723,6 +795,9 @@ def send_report(self):
             elif var_type == 3:  # string variable
                 conditions.append(
                     f"{attr} {name} {' and '.join(map(repr, values))}")
+            elif var_type == 4:  # time
+                values = (value.toString(format=Qt.ISODate) for value in values)
+                conditions.append(f"{attr} {name} {' and '.join(values)}")
             elif all(x for x in values):  # numeric variable
                 conditions.append(f"{attr} {name} {' and '.join(values)}")
         items = OrderedDict()
@@ -759,7 +834,7 @@ def send_report(self):
 
 
 class CheckBoxPopup(QWidget):
-    def __init__(self, var, lc, widget_parent=None, widget=None):
+    def __init__(self, var, lc, widget_parent=None, widg=None):
         QWidget.__init__(self)
 
         self.list_view = QListView()
@@ -783,7 +858,7 @@ def __init__(self, var, lc, widget_parent=None, widget=None):
         self.adjustSize()
         self.setWindowFlags(Qt.Popup)
 
-        self.widget = widget
+        self.widget = widg
         self.widget.desc_text = ', '.join(text)
         self.widget.set_text()
 
@@ -810,9 +885,79 @@ def set_text(self):
         self.setText(metrics.elidedText(self.desc_text, Qt.ElideRight,
                                         self.width() - 15))
 
-    def resizeEvent(self, QResizeEvent):
+    def resizeEvent(self, _):
         self.set_text()
 
 
+class DateTimeWidget(QDateTimeEdit):
+    def __init__(self, parent, column, datetime_format):
+        QDateTimeEdit.__init__(self, parent)
+
+        self.format = datetime_format
+        self.have_date, self.have_time = datetime_format[0], datetime_format[1]
+        self.set_format(column)
+        self.setSizePolicy(
+            QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding))
+
+    def set_format(self, column):
+        str_format = Qt.ISODate
+        if self.have_date and self.have_time:
+            self.setDisplayFormat("yyyy-MM-dd hh:mm:ss")
+            c_format = "%Y-%m-%d %H:%M:%S"
+            min_datetime, max_datetime = self.find_range(column, c_format)
+            self.min_datetime = QDateTime.fromString(min_datetime, str_format)
+            self.max_datetime = QDateTime.fromString(max_datetime, str_format)
+            self.setCalendarPopup(True)
+            self.calendarWidget = CalendarWidgetWithTime(self)
+            self.calendarWidget.timeedit.timeChanged.connect(
+                self.set_datetime)
+            self.setCalendarWidget(self.calendarWidget)
+            self.setDateTimeRange(self.min_datetime, self.max_datetime)
+
+        elif self.have_date and not self.have_time:
+            self.setDisplayFormat("yyyy-MM-dd")
+            self.setCalendarPopup(True)
+            min_datetime, max_datetime = self.find_range(column, "%Y-%m-%d")
+            self.min_datetime = QDate.fromString(min_datetime, str_format)
+            self.max_datetime = QDate.fromString(max_datetime, str_format)
+            self.setDateRange(self.min_datetime, self.max_datetime)
+
+        elif not self.have_date and self.have_time:
+            self.setDisplayFormat("hh:mm:ss")
+            min_datetime, max_datetime = self.find_range(column, "%H:%M:%S")
+            self.min_datetime = QTime.fromString(min_datetime, str_format)
+            self.max_datetime = QTime.fromString(max_datetime, str_format)
+            self.setTimeRange(self.min_datetime, self.max_datetime)
+
+    def set_datetime(self, date_time):
+        if not date_time:
+            date_time = self.min_datetime
+        if self.have_date and self.have_time:
+            if isinstance(date_time, QTime):
+                self.setDateTime(
+                    QDateTime(self.date(), self.calendarWidget.timeedit.time()))
+            else:
+                self.setDateTime(date_time)
+        elif self.have_date and not self.have_time:
+            self.setDate(date_time)
+        elif not self.have_date and self.have_time:
+            self.setTime(date_time)
+
+    @staticmethod
+    def find_range(column, convert_format):
+        def convert_timestamp(timestamp):
+            if timestamp >= 0:
+                return datetime.fromtimestamp(timestamp, tz=timezone.utc)
+            return datetime(1970, 1, 1, tzinfo=timezone.utc) + \
+                       timedelta(seconds=int(timestamp))
+
+        item_list = [item for items in list(column) for item in items]
+        min_datetime = convert_timestamp(
+            np.nanmin(item_list)).strftime(convert_format)
+        max_datetime = convert_timestamp(
+            np.nanmax(item_list)).strftime(convert_format)
+        return min_datetime, max_datetime
+
+
 if __name__ == "__main__":  # pragma: no cover
     WidgetPreview(OWSelectRows).run(Table("heart_disease"))


Test patch:
diff --git a/Orange/widgets/data/tests/test_owselectrows.py b/Orange/widgets/data/tests/test_owselectrows.py
--- a/Orange/widgets/data/tests/test_owselectrows.py
+++ b/Orange/widgets/data/tests/test_owselectrows.py
@@ -2,20 +2,19 @@
 # pylint: disable=missing-docstring,unsubscriptable-object
 import time
 from unittest.mock import Mock, patch
+import numpy as np
 
-from AnyQt.QtCore import QLocale, Qt
+from AnyQt.QtCore import QLocale, Qt, QDate
 from AnyQt.QtTest import QTest
 from AnyQt.QtWidgets import QLineEdit, QComboBox
 
-import numpy as np
-
 from Orange.data import (
     Table, Variable, ContinuousVariable, StringVariable, DiscreteVariable,
     Domain)
 from Orange.preprocess import discretize
 from Orange.widgets.data import owselectrows
 from Orange.widgets.data.owselectrows import (
-    OWSelectRows, FilterDiscreteType, SelectRowsContextHandler)
+    OWSelectRows, FilterDiscreteType, SelectRowsContextHandler, DateTimeWidget)
 from Orange.widgets.tests.base import WidgetTest, datasets
 
 from Orange.data.filter import FilterContinuous, FilterString
@@ -430,6 +429,42 @@ def test_keep_operator(self):
         self.assertEqual(
             self.widget.cond_list.cellWidget(0, 1).currentText(), "is")
 
+    def test_calendar_dates(self):
+        data = Table(test_filename("datasets/cyber-security-breaches.tab"))
+        self.send_signal(self.widget.Inputs.data, data)
+        simulate.combobox_activate_item(
+            self.widget.cond_list.cellWidget(0, 0), "Date_Posted_or_Updated",
+            delay=0)
+        value_combo = self.widget.cond_list.cellWidget(0, 2).children()[1]
+        self.assertIsInstance(value_combo, DateTimeWidget)
+
+        # first displayed date is min date
+        self.assertEqual(value_combo.date(), QDate(2014, 1, 23))
+        self.assertEqual(len(self.get_output("Matching Data")), 691)
+        self.widget.remove_all_button.click()
+        self.enterFilter("Date_Posted_or_Updated", "is below",
+                         QDate(2014, 4, 17))
+        self.assertEqual(len(self.get_output("Matching Data")), 840)
+        self.enterFilter("Date_Posted_or_Updated", "is greater than",
+                         QDate(2014, 6, 30))
+        self.assertIsNone(self.get_output("Matching Data"))
+        self.widget.remove_all_button.click()
+        # date is in range min-max date
+        self.enterFilter("Date_Posted_or_Updated", "equals", QDate(2013, 1, 1))
+        self.assertEqual(self.widget.conditions[0][2][0], QDate(2014, 1, 23))
+        self.enterFilter("Date_Posted_or_Updated", "equals", QDate(2015, 1, 1))
+        self.assertEqual(self.widget.conditions[1][2][0], QDate(2014, 6, 30))
+        self.widget.remove_all_button.click()
+        # no date crossings
+        self.enterFilter("Date_Posted_or_Updated", "is between",
+                         QDate(2014, 4, 17), QDate(2014, 1, 23))
+        self.assertEqual(self.widget.conditions[0][2],
+                         (QDate(2014, 4, 17), QDate(2014, 4, 17)))
+        self.widget.remove_all_button.click()
+        self.enterFilter("Date_Posted_or_Updated", "is between",
+                         QDate(2014, 4, 17), QDate(2014, 4, 30))
+        self.assertEqual(len(self.get_output("Matching Data")), 58)
+
     @patch.object(owselectrows.QMessageBox, "question",
                   return_value=owselectrows.QMessageBox.Ok)
     def test_add_all(self, msgbox):
@@ -581,9 +616,13 @@ def __get_value_widgets(self, row):
             if isinstance(value_inputs, QComboBox):
                 value_inputs = [value_inputs]
             else:
-                value_inputs = [
-                    w for w in value_inputs.children()
-                    if isinstance(w, QLineEdit)]
+                value_input = []
+                for widget in value_inputs.children():
+                    if isinstance(widget, QLineEdit):
+                        value_input.append(widget)
+                    elif isinstance(widget, DateTimeWidget):
+                        value_input.append(widget)
+                return value_input
         return value_inputs
 
     @staticmethod
@@ -594,5 +633,7 @@ def __set_value(widget, value):
             QTest.keyClick(widget, Qt.Key_Enter)
         elif isinstance(widget, QComboBox):
             simulate.combobox_activate_item(widget, value)
+        elif isinstance(widget, DateTimeWidget):
+            widget.setDate(value)
         else:
             raise ValueError("Unsupported widget {}".format(widget))