######################################################################################################################
# Copyright (C) 2017 - 2019 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 view class.
:authors: P. Savolainen (VTT), M. Marin (KHT), J. Olauson (KTH)
:date: 14.07.2018
"""
import logging
import os
from PySide2.QtCore import Qt, Slot, Signal, QUrl
from PySide2.QtGui import QStandardItem, QStandardItemModel, QIcon, QPixmap, QDesktopServices
from sqlalchemy.engine.url import URL
from spinedb_api import DiffDatabaseMapping, SpineDBAPIError, SpineDBVersionError
from project_item import ProjectItem
from widgets.graph_view_widget import GraphViewForm
from widgets.tabular_view_widget import TabularViewForm
from widgets.tree_view_widget import TreeViewForm
from graphics_items import ViewIcon
from helpers import create_dir
[docs]class View(ProjectItem):
"""
View class.
Attributes:
toolbox (ToolboxUI): QMainWindow instance
name (str): Object name
description (str): Object description
x (int): Initial X coordinate of item icon
y (int): Initial Y coordinate of item icon
"""
[docs] view_refresh_signal = Signal(name="view_refresh_signal")
def __init__(self, toolbox, name, description, x, y):
super().__init__(name, description)
self._toolbox = toolbox
self._project = self._toolbox.project()
self.item_type = "View"
self._graph_views = {}
self._tabular_views = {}
self._tree_views = {}
self._references = list()
self.reference_model = QStandardItemModel() # References to databases
self.spine_ref_icon = QIcon(QPixmap(":/icons/Spine_db_ref_icon.png"))
# Make project directory for this View
self.data_dir = os.path.join(self._project.project_dir, self.short_name)
try:
create_dir(self.data_dir)
except OSError:
self._toolbox.msg_error.emit(
"[OSError] Creating directory {0} failed." " Check permissions.".format(self.data_dir)
)
self._graphics_item = ViewIcon(self._toolbox, x - 35, y - 35, 70, 70, self.name)
# Note: view_refresh_signal is not shared with other project items so there is no need to disconnect it
self.view_refresh_signal.connect(self.refresh)
self._sigs = self.make_signal_handler_dict()
[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 = dict()
s[self._toolbox.ui.toolButton_view_open_dir.clicked] = self.open_directory
s[self._toolbox.ui.pushButton_view_open_graph_view.clicked] = self.open_graph_view_btn_clicked
s[self._toolbox.ui.pushButton_view_open_tabular_view.clicked] = self.open_tabular_view_btn_clicked
s[self._toolbox.ui.pushButton_view_open_tree_view.clicked] = self.open_tree_view_btn_clicked
return s
[docs] def activate(self):
"""Restore selections and connect signals."""
self.restore_selections()
super().connect_signals()
[docs] def deactivate(self):
"""Save selections and disconnect signals."""
self.save_selections()
if not super().disconnect_signals():
logging.error("Item %s deactivation failed", self.name)
return False
return True
[docs] def restore_selections(self):
"""Restore selections into shared widgets when this project item is selected."""
self._toolbox.ui.label_view_name.setText(self.name)
self._toolbox.ui.treeView_view.setModel(self.reference_model)
self.refresh()
[docs] def save_selections(self):
"""Save selections in shared widgets for this project item into instance variables."""
self._toolbox.ui.treeView_view.setModel(None)
[docs] def get_icon(self):
"""Returns the item representing this Data Store on the scene."""
return self._graphics_item
[docs] def references(self):
"""Returns a list of url strings that are in this item as references."""
return self._references
@Slot(name="refresh")
[docs] def refresh(self):
"""Update the list of references that this item is viewing."""
input_items = self.find_input_items()
self._references = list()
for item in input_items:
if item.item_type == "Data Store":
url = item.make_url()
if not url:
continue
self._references.append(url)
elif item.item_type == "Tool":
filepaths = []
for (dirpath, dirnames, filenames) in os.walk(item.output_dir):
filepaths.extend([os.path.join(dirpath, fn) for fn in filenames])
self._references.extend([URL("sqlite", database=f) for f in filepaths if f.lower().endswith('.sqlite')])
# logging.debug("{0}".format(self._references))
self.populate_reference_list(self._references)
@Slot(bool, name="open_graph_view_btn_clicked")
[docs] def open_graph_view_btn_clicked(self, checked=False):
"""Slot for handling the signal emitted by clicking on 'Graph view' button."""
self._open_view(self._graph_views, supports_multiple_databases=False)
@Slot(bool, name="open_tabular_view_btn_clicked")
[docs] def open_tabular_view_btn_clicked(self, checked=False):
"""Slot for handling the signal emitted by clicking on 'Tabular view' button."""
self._open_view(self._tabular_views, supports_multiple_databases=False)
@Slot(bool, name="open_tree_view_btn_clicked")
[docs] def open_tree_view_btn_clicked(self, checked=False):
"""Slot for handling the signal emitted by clicking on 'Tree view' button."""
self._open_view(self._tree_views, supports_multiple_databases=True)
[docs] def _open_view(self, view_store, supports_multiple_databases):
"""
Opens references in a view window.
Args:
view_store (dict): a dictionary where to store the view window
supports_multiple_databases (bool): True if the view supports more than one database
"""
indexes = self._selected_indexes()
db_maps, databases = self._database_maps(indexes)
# Mangle database paths to get a hashable string identifying the view window.
view_id = ";".join(sorted(databases))
if not supports_multiple_databases and len(db_maps) > 1:
# Currently, Graph and Tabular views do not support multiple databases.
# This if clause can be removed once that support has been implemented.
self._toolbox.msg_error.emit("Selected view does not support multiple databases.")
return
if self._restore_existing_view_window(view_id, view_store):
return
view_window = self._make_view_window(view_store, db_maps, databases)
view_window.show()
view_window.destroyed.connect(lambda: view_store.pop(view_id))
view_store[view_id] = view_window
[docs] def close_all_views(self):
"""Closes all view windows."""
for view in self._graph_views.values():
view.close()
for view in self._tabular_views.values():
view.close()
for view in self._tree_views.values():
view.close()
[docs] def populate_reference_list(self, items):
"""Add given list of items to the reference model. If None or
an empty list given, the model is cleared."""
self.reference_model.clear()
self.reference_model.setHorizontalHeaderItem(0, QStandardItem("References")) # Add header
if items is not None:
for item in items:
qitem = QStandardItem(item.database)
qitem.setFlags(~Qt.ItemIsEditable)
qitem.setData(self.spine_ref_icon, Qt.DecorationRole)
self.reference_model.appendRow(qitem)
[docs] def update_name_label(self):
"""Update View tab name label. Used only when renaming project items."""
self._toolbox.ui.label_view_name.setText(self.name)
@Slot(bool, name="open_directory")
[docs] def open_directory(self, checked=False):
"""Open file explorer in View data directory."""
url = "file:///" + self.data_dir
# noinspection PyTypeChecker, PyCallByClass, PyArgumentList
res = QDesktopServices.openUrl(QUrl(url, QUrl.TolerantMode))
if not res:
self._toolbox.msg_error.emit("Failed to open directory: {0}".format(self.data_dir))
[docs] def execute(self):
"""Executes this View."""
self._toolbox.msg.emit("")
self._toolbox.msg.emit("Executing View <b>{0}</b>".format(self.name))
self._toolbox.msg.emit("***")
self._toolbox.project().execution_instance.project_item_execution_finished_signal.emit(0) # 0 success
[docs] def stop_execution(self):
"""Stops executing this View."""
self._toolbox.msg.emit("Stopping {0}".format(self.name))
[docs] def _selected_indexes(self):
"""Returns selected indexes."""
selection_model = self._toolbox.ui.treeView_view.selectionModel()
if not selection_model.hasSelection():
self._toolbox.ui.treeView_view.selectAll()
return self._toolbox.ui.treeView_view.selectionModel().selectedRows()
[docs] def _database_maps(self, indexes):
"""Returns database maps and database paths for given indexes."""
db_maps = dict()
databases = list()
for index in indexes:
url = self._references[index.row()]
try:
db_map = DiffDatabaseMapping(url, url.username)
except (SpineDBAPIError, SpineDBVersionError) as e:
self._toolbox.msg_error.emit(e.msg)
return
database = url.database
db_maps[database] = db_map
databases.append(database)
return db_maps, databases
@staticmethod
[docs] def _restore_existing_view_window(view_id, view_store):
"""Restores an existing view window and returns True if the operation was successful."""
if view_id not in view_store:
return False
view_window = view_store[view_id]
if view_window.windowState() & Qt.WindowMinimized:
view_window.setWindowState(view_window.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
view_window.activateWindow()
return True
[docs] def _make_view_window(self, view_store, db_maps, databases):
if view_store is self._graph_views:
return GraphViewForm(self, db_maps, read_only=True)
if view_store is self._tabular_views:
return TabularViewForm(self, db_maps[databases[0]], databases[0])
if view_store is self._tree_views:
return TreeViewForm(self._project, db_maps)
raise RuntimeError("view_store must be self._graph_views, self._tabular_views or self._tree_views")