From a2affc44477fb9cc394c699a1f7f9757bfa81c21 Mon Sep 17 00:00:00 2001 From: Antti Soininen Date: Mon, 19 Aug 2024 08:51:51 +0300 Subject: [PATCH 1/3] Improve pasting data from Excel Toolbox is now able to handle Excel's clipboard data properly. The data is not treated as plain CSV text anymore but instead we use the full type information which enables pasting date, times, booleans and such from Excel. Re #1204 --- CHANGELOG.md | 1 + spinetoolbox/mvcmodels/array_model.py | 15 +- .../mvcmodels/indexed_value_table_model.py | 4 + spinetoolbox/mvcmodels/time_pattern_model.py | 8 + .../time_series_model_fixed_resolution.py | 6 + .../time_series_model_variable_resolution.py | 18 +- spinetoolbox/ui/datetime_editor.py | 4 +- spinetoolbox/ui/datetime_editor.ui | 3 +- spinetoolbox/widgets/custom_qtableview.py | 413 +++++++++--------- spinetoolbox/widgets/datetime_editor.py | 14 +- spinetoolbox/widgets/duration_editor.py | 2 +- .../widgets/parameter_value_editor_base.py | 44 +- spinetoolbox/widgets/paste_excel.py | 96 ++++ .../widgets/plain_parameter_value_editor.py | 6 +- tests/mock_helpers.py | 17 +- .../widgets/test_add_items_dialog.py | 13 +- .../widgets/test_custom_qtableview.py | 14 +- .../test_edit_or_remove_items_dialogs.py | 21 +- tests/widgets/test_ArrayTableView.py | 151 +++---- tests/widgets/test_CopyPasteTableView.py | 11 +- tests/widgets/test_IndexedValueTableView.py | 36 +- tests/widgets/test_MapTableView.py | 25 +- ...test_TimeSeriesFixedResolutionTableView.py | 60 ++- tests/widgets/test_paste_excel.py | 297 +++++++++++++ 24 files changed, 880 insertions(+), 399 deletions(-) create mode 100644 spinetoolbox/widgets/paste_excel.py create mode 100644 tests/widgets/test_paste_excel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c757d07d..547330a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) - Database editor's hamburger menu has been converted into a menubar beneath the tab bar. The URL toolbar has also been replaced with a toolbar that houses different buttons. - One dimensional entities can now also be added through *Parameter value* and *Entity alternative* tables. +- Special data types like dates, times and booleans are now properly pasted from Excel. ### Deprecated diff --git a/spinetoolbox/mvcmodels/array_model.py b/spinetoolbox/mvcmodels/array_model.py index 73b33bea2..7326cba4f 100644 --- a/spinetoolbox/mvcmodels/array_model.py +++ b/spinetoolbox/mvcmodels/array_model.py @@ -13,6 +13,7 @@ """Contains model for the Array editor widget.""" import locale from numbers import Number +from typing import Type import numpy from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt from spinedb_api import Array, ParameterValueFormatError, SpineDBAPIError, from_database @@ -38,6 +39,10 @@ def __init__(self, parent): self._data_type = float self._index_name = Array.DEFAULT_INDEX_NAME + @property + def data_type(self) -> Type: + return self._data_type + def array(self): """Returns the array modeled by this model.""" return Array(self._data, self._data_type, self._index_name) @@ -53,7 +58,7 @@ def batch_set_data(self, indexes, values): return top_row = indexes[0].row() bottom_row = top_row - indexes, values = self._convert_to_data_type(indexes, values) + # indexes, values = self._convert_to_data_type(indexes, values) if not indexes: return for index, value in zip(indexes, values): @@ -63,8 +68,8 @@ def batch_set_data(self, indexes, values): if row == len(self._data): self.insertRow(len(self._data)) self._data[row] = value - top_left = self.index(top_row, 0) - bottom_right = self.index(bottom_row, 0) + top_left = self.index(top_row, 1) + bottom_right = self.index(bottom_row, 1) self.dataChanged.emit( top_left, bottom_right, @@ -86,6 +91,8 @@ def _convert_to_data_type(self, indexes, values): Returns: tuple: indexes and converted values """ + if all(isinstance(v, self._data_type) for v in values): + return indexes, values filtered = [] converted = [] if self._data_type == float: @@ -113,6 +120,8 @@ def _convert_to_data_type(self, indexes, values): continue except SpineDBAPIError: pass + if not isinstance(value, str): + continue try: data = from_database(value, self._data_type.type_()) if isinstance(data, self._data_type): diff --git a/spinetoolbox/mvcmodels/indexed_value_table_model.py b/spinetoolbox/mvcmodels/indexed_value_table_model.py index d7e7f6931..5c2292efb 100644 --- a/spinetoolbox/mvcmodels/indexed_value_table_model.py +++ b/spinetoolbox/mvcmodels/indexed_value_table_model.py @@ -33,6 +33,10 @@ def columnCount(self, parent=QModelIndex()): """Returns the number of columns which is two.""" return 2 + def column_type(self, column): + """Returns column's type.""" + raise NotImplementedError() + def data(self, index, role=Qt.ItemDataRole.DisplayRole): """Returns the data at index for given role.""" if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): diff --git a/spinetoolbox/mvcmodels/time_pattern_model.py b/spinetoolbox/mvcmodels/time_pattern_model.py index b389609fd..7ccb08781 100644 --- a/spinetoolbox/mvcmodels/time_pattern_model.py +++ b/spinetoolbox/mvcmodels/time_pattern_model.py @@ -21,6 +21,14 @@ class TimePatternModel(IndexedValueTableModel): """A model for time pattern type parameter values.""" + def column_type(self, column): + """Returns column's type.""" + if column == 0: + return str + if column == 1: + return float + raise RuntimeError("Logic error: column out of bounds") + def flags(self, index): """Returns flags at index.""" if not index.isValid(): diff --git a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py index db53a34a9..adbd1b2e0 100644 --- a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py +++ b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py @@ -29,6 +29,12 @@ def __init__(self, series, parent): super().__init__(series, parent) self.locale = QLocale() + def column_type(self, column): + """Returns column's type.""" + if column == 1: + return float + raise RuntimeError("Logic error: column out of bounds") + def flags(self, index): """Returns flags at index.""" if not index.isValid(): diff --git a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py index 57b54cbbe..42950bc16 100644 --- a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py +++ b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py @@ -20,6 +20,14 @@ class TimeSeriesModelVariableResolution(IndexedValueTableModel): """A model for variable resolution time series type parameter values.""" + def column_type(self, column): + """Returns column's type.""" + if column == 0: + return np.datetime64 + if column == 1: + return float + raise RuntimeError("Logic error: column out of bounds") + def flags(self, index): """Returns the flags for given model index.""" if not index.isValid(): @@ -164,20 +172,20 @@ def batch_set_data(self, indexes, values): indexes (Sequence): a sequence of model indexes values (Sequence): a sequence of datetimes/floats corresponding to the indexes """ - modified_rows = [] - modified_columns = [] + modified_rows = set() + modified_columns = set() for index, value in zip(indexes, values): row = index.row() - modified_rows.append(row) + modified_rows.add(row) column = index.column() - modified_columns.append(column) + modified_columns.add(column) if column == 0: self._value.indexes[row] = value else: self._value.values[row] = value left_top = self.index(min(modified_rows), min(modified_columns)) right_bottom = self.index(max(modified_rows), max(modified_columns)) - self.dataChanged.emit(left_top, right_bottom, [Qt.ItemDataRole.EditRole]) + self.dataChanged.emit(left_top, right_bottom, [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole]) @Slot(bool, name="set_ignore_year") def set_ignore_year(self, ignore_year): diff --git a/spinetoolbox/ui/datetime_editor.py b/spinetoolbox/ui/datetime_editor.py index beebea59a..8724c1000 100644 --- a/spinetoolbox/ui/datetime_editor.py +++ b/spinetoolbox/ui/datetime_editor.py @@ -14,7 +14,7 @@ ################################################################################ ## Form generated from reading UI file 'datetime_editor.ui' ## -## Created by: Qt User Interface Compiler version 6.5.2 +## Created by: Qt User Interface Compiler version 6.6.3 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -72,6 +72,6 @@ def retranslateUi(self, DatetimeEditor): DatetimeEditor.setWindowTitle(QCoreApplication.translate("DatetimeEditor", u"Form", None)) self.datetime_edit_label.setText(QCoreApplication.translate("DatetimeEditor", u"Datetime", None)) self.datetime_edit.setDisplayFormat(QCoreApplication.translate("DatetimeEditor", u"yyyy-MM-ddTHH:mm:ss", None)) - self.format_label.setText(QCoreApplication.translate("DatetimeEditor", u"Format: YYYY--MM-DDThh:mm:ss", None)) + self.format_label.setText(QCoreApplication.translate("DatetimeEditor", u"Format: YYYY-MM-DDThh:mm:ss", None)) # retranslateUi diff --git a/spinetoolbox/ui/datetime_editor.ui b/spinetoolbox/ui/datetime_editor.ui index 0036ae7e2..4639f8a31 100644 --- a/spinetoolbox/ui/datetime_editor.ui +++ b/spinetoolbox/ui/datetime_editor.ui @@ -2,6 +2,7 @@