######################################################################################################################
# Copyright (C) 2017-2020 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Module for data store class.
:authors: P. Savolainen (VTT), M. Marin (KTH)
:date: 18.12.2017
"""
import os
from PySide2.QtCore import Slot
from PySide2.QtWidgets import QFileDialog, QApplication
from spinetoolbox.project_item import ProjectItem
from spinetoolbox.project_item_resource import ProjectItemResource
from spinetoolbox.helpers import busy_effect, serialize_path, deserialize_path
from .commands import UpdateDSURLCommand
from .executable_item import ExecutableItem
from .item_info import ItemInfo
from .utils import convert_to_sqlalchemy_url
from .widgets.custom_menus import DataStoreContextMenu
[docs]class DataStore(ProjectItem):
def __init__(self, toolbox, project, logger, name, description, x, y, url=None):
"""Data Store class.
Args:
toolbox (ToolboxUI): QMainWindow instance
project (SpineToolboxProject): the project this item belongs to
logger (LoggerInterface): a logger instance
name (str): Object name
description (str): Object description
x (float): Initial X coordinate of item icon
y (float): Initial Y coordinate of item icon
url (str or dict): SQLAlchemy url
"""
super().__init__(name, description, x, y, project, logger)
if url is None:
url = dict()
if url and not isinstance(url["database"], str):
url["database"] = deserialize_path(url["database"], self._project.project_dir)
self._toolbox = toolbox
self._url = self.parse_url(url)
self._sa_url = None
@staticmethod
[docs] def item_type():
"""See base class."""
return ItemInfo.item_type()
@staticmethod
[docs] def item_category():
"""See base class."""
return ItemInfo.item_category()
[docs] def execution_item(self):
"""Creates DataStore's execution counterpart."""
self._update_sa_url(log_errors=False)
return ExecutableItem(self.name, self._sa_url, self._logger)
[docs] def parse_url(self, url):
"""Return a complete url dictionary from the given dict or string"""
base_url = dict(dialect="", username="", password="", host="", port="", database="")
if isinstance(url, dict):
if "database" in url and url["database"] is not None:
if url["database"].lower().endswith(".sqlite"):
# Convert relative database path back to absolute
abs_path = os.path.abspath(os.path.join(self._project.project_dir, url["database"]))
url["database"] = abs_path
for key, value in url.items():
if value is not None:
base_url[key] = value
return base_url
[docs] def make_signal_handler_dict(self):
"""Returns a dictionary of all shared signals and their handlers.
This is to enable simpler connecting and disconnecting."""
s = super().make_signal_handler_dict()
s[self._properties_ui.toolButton_ds_open_dir.clicked] = lambda checked=False: self.open_directory()
s[self._properties_ui.pushButton_ds_open_editor.clicked] = self.open_ds_form
s[self._properties_ui.toolButton_open_sqlite_file.clicked] = self.open_sqlite_file
s[self._properties_ui.pushButton_create_new_spine_db.clicked] = self.create_new_spine_database
s[self._properties_ui.toolButton_copy_url.clicked] = self.copy_url
s[self._properties_ui.comboBox_dialect.activated[str]] = self.refresh_dialect
s[self._properties_ui.lineEdit_database.file_dropped] = self.set_path_to_sqlite_file
s[self._properties_ui.lineEdit_username.editingFinished] = self.refresh_username
s[self._properties_ui.lineEdit_password.editingFinished] = self.refresh_password
s[self._properties_ui.lineEdit_host.editingFinished] = self.refresh_host
s[self._properties_ui.lineEdit_port.editingFinished] = self.refresh_port
s[self._properties_ui.lineEdit_database.editingFinished] = self.refresh_database
return s
[docs] def restore_selections(self):
"""Load url into selections."""
self._properties_ui.label_ds_name.setText(self.name)
self.load_url_into_selections(self._url)
[docs] def url(self):
"""Returns the url attribute."""
return self._url
[docs] def sql_alchemy_url(self):
"""Returns the URL as an SQLAlchemy URL object or None if no URL is set."""
self._update_sa_url()
return self._sa_url
@busy_effect
[docs] def _update_sa_url(self, log_errors=True):
self._sa_url = convert_to_sqlalchemy_url(self._url, self.name, self._logger, log_errors)
[docs] def project(self):
"""Returns current project or None if no project open."""
return self._project
@Slot("QString")
[docs] def set_path_to_sqlite_file(self, file_path):
"""Set path to SQLite file."""
abs_path = os.path.abspath(file_path)
self.update_url(database=abs_path)
@Slot(bool)
[docs] def open_sqlite_file(self, checked=False):
"""Open file browser where user can select the path to an SQLite
file that they want to use."""
# noinspection PyCallByClass, PyTypeChecker, PyArgumentList
answer = QFileDialog.getOpenFileName(self._toolbox, 'Select SQLite file', self.data_dir)
file_path = answer[0]
if not file_path: # Cancel button clicked
return
# Update UI
self.set_path_to_sqlite_file(file_path)
[docs] def load_url_into_selections(self, url):
"""Load given url attribute into shared widget selections.
"""
if not self._active:
return
if not url:
return
dialect = url.get("dialect")
host = url.get("host")
port = url.get("port")
database = url.get("database")
username = url.get("username")
password = url.get("password")
if dialect is not None:
self.enable_dialect(dialect)
if dialect == "":
self._properties_ui.comboBox_dialect.setCurrentIndex(-1)
else:
self._properties_ui.comboBox_dialect.setCurrentText(dialect)
if host is not None:
self._properties_ui.lineEdit_host.setText(host)
if port is not None:
self._properties_ui.lineEdit_port.setText(str(port))
if database is not None:
if database:
database = os.path.abspath(database)
self._properties_ui.lineEdit_database.setText(database)
if username is not None:
self._properties_ui.lineEdit_username.setText(username)
if password is not None:
self._properties_ui.lineEdit_password.setText(password)
[docs] def update_url(self, **kwargs):
"""Set url key to value."""
kwargs = {k: v for k, v in kwargs.items() if v != self._url[k]}
if not kwargs:
return False
self._toolbox.undo_stack.push(UpdateDSURLCommand(self, **kwargs))
return True
[docs] def do_update_url(self, **kwargs):
self._url.update(kwargs)
self.item_changed.emit()
self.load_url_into_selections(kwargs)
@Slot()
[docs] def refresh_host(self):
"""Refresh host from selections."""
host = self._properties_ui.lineEdit_host.text()
self.update_url(host=host)
@Slot()
[docs] def refresh_port(self):
"""Refresh port from selections."""
port = self._properties_ui.lineEdit_port.text()
self.update_url(port=port)
@Slot()
[docs] def refresh_database(self):
"""Refresh database from selections."""
database = self._properties_ui.lineEdit_database.text()
self.update_url(database=database)
@Slot()
[docs] def refresh_username(self):
"""Refresh username from selections."""
username = self._properties_ui.lineEdit_username.text()
self.update_url(username=username)
@Slot()
[docs] def refresh_password(self):
"""Refresh password from selections."""
password = self._properties_ui.lineEdit_password.text()
self.update_url(password=password)
@Slot("QString")
[docs] def refresh_dialect(self, dialect):
self.update_url(dialect=dialect)
[docs] def enable_dialect(self, dialect):
"""Enable the given dialect in the item controls."""
if dialect == "":
self.enable_no_dialect()
elif dialect == 'sqlite':
self.enable_sqlite()
elif dialect == 'mssql':
import pyodbc # pylint: disable=import-outside-toplevel
dsns = pyodbc.dataSources()
# Collect dsns which use the msodbcsql driver
mssql_dsns = list()
for key, value in dsns.items():
if 'msodbcsql' in value.lower():
mssql_dsns.append(key)
if mssql_dsns:
self._properties_ui.comboBox_dsn.clear()
self._properties_ui.comboBox_dsn.addItems(mssql_dsns)
self._properties_ui.comboBox_dsn.setCurrentIndex(-1)
self.enable_mssql()
else:
msg = "Please create a SQL Server ODBC Data Source first."
self._logger.msg_warning.emit(msg)
else:
self.enable_common()
[docs] def enable_no_dialect(self):
"""Adjust widget enabled status to default when no dialect is selected."""
self._properties_ui.comboBox_dialect.setEnabled(True)
self._properties_ui.comboBox_dsn.setEnabled(False)
self._properties_ui.toolButton_open_sqlite_file.setEnabled(False)
self._properties_ui.lineEdit_host.setEnabled(False)
self._properties_ui.lineEdit_port.setEnabled(False)
self._properties_ui.lineEdit_database.setEnabled(False)
self._properties_ui.lineEdit_username.setEnabled(False)
self._properties_ui.lineEdit_password.setEnabled(False)
[docs] def enable_mssql(self):
"""Adjust controls to mssql connection specification."""
self._properties_ui.comboBox_dsn.setEnabled(True)
self._properties_ui.toolButton_open_sqlite_file.setEnabled(False)
self._properties_ui.lineEdit_host.setEnabled(False)
self._properties_ui.lineEdit_port.setEnabled(False)
self._properties_ui.lineEdit_database.setEnabled(False)
self._properties_ui.lineEdit_username.setEnabled(True)
self._properties_ui.lineEdit_password.setEnabled(True)
self._properties_ui.lineEdit_host.clear()
self._properties_ui.lineEdit_port.clear()
self._properties_ui.lineEdit_database.clear()
[docs] def enable_sqlite(self):
"""Adjust controls to sqlite connection specification."""
self._properties_ui.comboBox_dsn.setEnabled(False)
self._properties_ui.comboBox_dsn.setCurrentIndex(-1)
self._properties_ui.toolButton_open_sqlite_file.setEnabled(True)
self._properties_ui.lineEdit_host.setEnabled(False)
self._properties_ui.lineEdit_port.setEnabled(False)
self._properties_ui.lineEdit_database.setEnabled(True)
self._properties_ui.lineEdit_username.setEnabled(False)
self._properties_ui.lineEdit_password.setEnabled(False)
self._properties_ui.lineEdit_host.clear()
self._properties_ui.lineEdit_port.clear()
self._properties_ui.lineEdit_username.clear()
self._properties_ui.lineEdit_password.clear()
[docs] def enable_common(self):
"""Adjust controls to 'common' connection specification."""
self._properties_ui.comboBox_dsn.setEnabled(False)
self._properties_ui.comboBox_dsn.setCurrentIndex(-1)
self._properties_ui.toolButton_open_sqlite_file.setEnabled(False)
self._properties_ui.lineEdit_host.setEnabled(True)
self._properties_ui.lineEdit_port.setEnabled(True)
self._properties_ui.lineEdit_database.setEnabled(True)
self._properties_ui.lineEdit_username.setEnabled(True)
self._properties_ui.lineEdit_password.setEnabled(True)
@Slot(bool)
[docs] def data_files(self):
"""Return a list of files that are in this items data directory."""
if not os.path.isdir(self.data_dir):
return None
return os.listdir(self.data_dir)
@Slot(bool)
[docs] def copy_url(self, checked=False):
"""Copy db url to clipboard."""
self._update_sa_url()
if not self._sa_url:
return
self._sa_url.password = None
QApplication.clipboard().setText(str(self._sa_url))
self._logger.msg.emit(f"Database url <b>{self._sa_url}</b> copied to clipboard")
@Slot(bool)
[docs] def create_new_spine_database(self, checked=False):
"""Create new (empty) Spine database."""
# Try to make an url from the current status
self._update_sa_url(log_errors=False)
if not self._sa_url:
self._logger.msg_warning.emit(
f"Unable to generate URL from <b>{self.name}</b> selections. Defaults will be used..."
)
dialect = "sqlite"
database = os.path.abspath(os.path.join(self.data_dir, self.name + ".sqlite"))
self.update_url(dialect=dialect, database=database)
self._project.db_mngr.create_new_spine_database(self._sa_url)
[docs] def update_name_label(self):
"""Update Data Store tab name label. Used only when renaming project items."""
self._properties_ui.label_ds_name.setText(self.name)
[docs] def _do_handle_dag_changed(self, resources):
"""See base class."""
self._update_sa_url(log_errors=False)
if not self._sa_url:
self.add_notification(
"The URL for this Data Store is not correctly set. Set it in the Data Store Properties panel."
)
[docs] def item_dict(self):
"""Returns a dictionary corresponding to this item."""
d = super().item_dict()
d["url"] = dict(self.url())
# If database key is a file, change the path to relative
if d["url"]["dialect"] == "sqlite" and d["url"]["database"]:
d["url"]["database"] = serialize_path(d["url"]["database"], self._project.project_dir)
return d
@staticmethod
[docs] def upgrade_from_no_version_to_version_1(item_name, old_item_dict, old_project_dir):
"""See base class."""
new_data_store = dict(old_item_dict)
if "reference" in new_data_store:
url_path = new_data_store["reference"]
url = {"dialect": "sqlite", "username": None, "host": None, "port": None}
else:
url = new_data_store["url"]
url_path = url["database"]
if not url_path:
url["database"] = None
else:
serialized_url_path = serialize_path(url_path, old_project_dir)
if serialized_url_path["relative"]:
serialized_url_path["path"] = os.path.join(".spinetoolbox", "items", serialized_url_path["path"])
url["database"] = serialized_url_path
new_data_store["url"] = url
return new_data_store
@staticmethod
[docs] def rename(self, new_name):
"""Rename this item.
Args:
new_name (str): New name
Returns:
bool: True if renaming succeeded, False otherwise
"""
old_data_dir = os.path.abspath(self.data_dir) # Old data_dir before rename
if not super().rename(new_name):
return False
# If dialect is sqlite and db line edit refers to a file in the old data_dir, db line edit needs updating
if self._url["dialect"] == "sqlite":
db_dir, db_filename = os.path.split(os.path.abspath(self._url["database"].strip()))
if db_dir == old_data_dir:
database = os.path.join(self.data_dir, db_filename) # NOTE: data_dir has been updated at this point
# Check that the db was moved successfully to the new data_dir
if os.path.exists(database):
self.do_update_url(database=database)
return True
[docs] def notify_destination(self, source_item):
"""See base class."""
if source_item.item_type() == "Importer":
self._logger.msg.emit(
f"Link established. Mapped data generated by <b>{source_item.name}</b> will be "
f"imported in <b>{self.name}</b> when executing."
)
elif source_item.item_type() == "Combiner":
self._logger.msg.emit(
"Link established. "
f"Data from <b>{source_item.name}</b> will be merged into <b>{self.name}'s upon execution</b>."
)
elif source_item.item_type() in ["Data Connection", "Tool", "Gimlet"]:
# Does this type of link do anything?
self._logger.msg.emit("Link established")
else:
super().notify_destination(source_item)
@staticmethod
[docs] def default_name_prefix():
"""see base class"""
return "Data Store"
[docs] def resources_for_direct_successors(self):
"""See base class."""
self._update_sa_url(log_errors=False)
if self._sa_url:
resource = ProjectItemResource(self, "database", url=str(self._sa_url))
return [resource]
self.add_notification(
"The URL for this Data Store is not correctly set. Set it in the Data Store Properties panel."
)
return list()