diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 70d1b394e..9dad1d45e 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -1,4 +1,3 @@ -# Workflow stub to build Toolbox bundles. name: PyInstaller Bundle on: workflow_dispatch @@ -6,13 +5,59 @@ on: workflow_dispatch jobs: bundle: name: Bundle - strategy: - fail-fast: True - matrix: - python-version: ["3.11"] - os: [windows-latest] runs-on: windows-latest steps: - - name: Print notification + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install dependencies run: | - echo "This workflow is stub and does not do anything meaningful." + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + - name: Run tests + run: | + python -m unittest discover --verbose + - name: Install PyInstaller + run: | + python -m pip install PyInstaller + - name: Download embeddable Python + run: | + mkdir embedded-python + cd embedded-python + curl -o python.zip https://www.python.org/ftp/python/3.11.9/python-3.11.9-embed-amd64.zip + tar xf python.zip + del python.zip + - name: Build bundle + run: | + python -m PyInstaller spinetoolbox.spec -- --embedded-python=embedded-python + - name: Get Toolbox version + id: toolbox-version + shell: bash + run: | + python -c "from importlib.metadata import version; print('version=' + version('spinetoolbox'))" >> $GITHUB_OUTPUT + - name: Upload archive + uses: actions/upload-artifact@v4 + with: + name: Spine Toolbox ${{ steps.toolbox-version.outputs.version }} + path: dist + if-no-files-found: error + overwrite: true + update-downloads-page: + name: Trigger Downloads page generation + needs: bundle + runs-on: ubuntu-latest + steps: + - name: Trigger workflow + run: | + curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.DOWNLOADS_TRIGGER_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/spine-tools/Downloads/actions/workflows/generate_readme.yml/dispatches \ + -d '{"ref":"main"}' diff --git a/.github/workflows/test_runner.yml b/.github/workflows/test_runner.yml index 76bc944a6..5ca43dedb 100644 --- a/.github/workflows/test_runner.yml +++ b/.github/workflows/test_runner.yml @@ -6,7 +6,7 @@ on: push: paths: - "**.py" - - ".github/workflows/*.yml" + - ".github/workflows/test_runner.yml" - "requirements.txt" - "pyproject.toml" - "execution_tests/**" @@ -21,15 +21,19 @@ jobs: python-version: [3.8, 3.9, "3.10", 3.11] os: [windows-latest, ubuntu-22.04] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Version from Git tags run: git describe --tags - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + pyproject.toml - name: Display Python version run: python -c "import sys; print(sys.version)" @@ -45,12 +49,15 @@ jobs: python -m pip install --upgrade pip python -m pip install -r requirements.txt - name: List packages - run: + run: python -m pip list - name: Install python3 kernelspecs run: | python -m pip install ipykernel python -m ipykernel install --user + - name: Install coverage + run: + python -m pip install coverage[toml] - name: Run tests run: | if [ "$RUNNER_OS" != "Windows" ]; then @@ -59,8 +66,9 @@ jobs: coverage run -m unittest discover --verbose shell: bash - name: Upload coverage report to Codecov - uses: codecov/codecov-action@v3 - + uses: codecov/codecov-action@v4 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} execution-tests: name: Execution tests runs-on: ${{ matrix.os }} @@ -69,15 +77,17 @@ jobs: matrix: python-version: [3.8, 3.9, "3.10", 3.11] os: [windows-latest, ubuntu-22.04] - needs: unit-tests +# needs: unit-tests steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + requirements.txt + pyproject.toml - name: Install additional packages for Linux if: runner.os == 'Linux' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ec97a64ed..1f973ec52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,53 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Added +- New context menu action (Select superclass) for entity class items in the entity tree. +- Added Tool Specification type (Python, Gams, etc.) icons on Design View. +- There is now a new filter type, Alternative filter available in Link properties. + Unlike scenario filters, the execution is not parallelized. + Instead, a successor item sees parameter values of all selected alternatives. + Because of this behavior, + alternative filters cannot be used at the same time with scenario filters. + Link properties tab has a combo box that lets one choose which filter type to use. ### Changed +#### Spine data structure + +Many parts of the Spine data structure have been redesigned. + +- *Entities* have replaced objects and relationships. + Zero-dimensional entities correspond to objects while multidimensional entities replace the former relationships. + Unlike relationships, the *elements* of multidimensional entities can now be other multidimensional entities. +- Simple inheritance is now supported by *superclasses*. +- Tools, features and methods have been removed. + The functionality that was previously implemented using the is_active parameter + has been replaced by *entity alternatives*. + Entity classes have a default setting for the entity alternative called *active by default*. + Database migration should automatically replace tools, features and methods + by entity alternatives and set active by default to whatever default value `is_active` + or similar parameter had. + The `is_active` parameter is not removed from entity classes but its values are. +- Note that new zero-dimensional entity classes have *active by default* set to `false` initially. + This means that the entities of those classes are hidden when using scenario filters + unless specifically shown using entity alternatives. + +#### Miscellaneous changes + +- "Rubber band" selection of items in Design and Graph views is now done with **left mouse button** + (no need to press Ctrl anymore). The views can be dragged around by holding the **right mouse button**. +- Spine Database Editor now remembers the configuration of the docs in each view for a specific URL. The docks + can be reset from the hamburger menu **View->Docks...->Reset docks**. +- You can now select a different Julia executable & project or Julia kernel for each Tool spec. + This overrides the global setting from Toolbox Settings. +- Headless mode now supports remote execution (see 'python -m spinetoolbox --help') +- Commit Viewer's UI has undergone some redesigning and can now handle large databases. + ### Deprecated ### Removed +- Project dock widget +- Dependency on Dagster ### Fixed @@ -30,14 +71,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) - On Toolbox startup, a link appears in Event log that opens Upgrade notification window offering information about the upcoming 0.8 update. -## [0.7.2] +## [0.7.2] - 2023-12-04 ### Added - Data Connection items now support schemas in database references - Importer Specification Editor now supports database schemas -## [0.7.1] +## [0.7.1] - 2023-10-06 ### Added diff --git a/PyInstaller hooks/hook-datapackage.py b/PyInstaller hooks/hook-datapackage.py new file mode 100644 index 000000000..d8c22720e --- /dev/null +++ b/PyInstaller hooks/hook-datapackage.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("datapackage") diff --git a/PyInstaller hooks/hook-jill.py b/PyInstaller hooks/hook-jill.py new file mode 100644 index 000000000..e0ad0fa5a --- /dev/null +++ b/PyInstaller hooks/hook-jill.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("jill", subdir="config") diff --git a/PyInstaller hooks/hook-spine_engine.py b/PyInstaller hooks/hook-spine_engine.py new file mode 100644 index 000000000..b776c3ba5 --- /dev/null +++ b/PyInstaller hooks/hook-spine_engine.py @@ -0,0 +1,5 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files( + "spine_engine", subdir="execution_managers", includes=("spine_repl.*",), include_py_files=True +) diff --git a/PyInstaller hooks/hook-spinedb_api.py b/PyInstaller hooks/hook-spinedb_api.py new file mode 100644 index 000000000..872c6cc4a --- /dev/null +++ b/PyInstaller hooks/hook-spinedb_api.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("spinedb_api", subdir="alembic", include_py_files=True) diff --git a/PyInstaller hooks/hook-tableschema.py b/PyInstaller hooks/hook-tableschema.py new file mode 100644 index 000000000..c958b2d1f --- /dev/null +++ b/PyInstaller hooks/hook-tableschema.py @@ -0,0 +1,6 @@ +from PyInstaller.utils.hooks import collect_data_files + +package = "tableschema" + +datas = collect_data_files(package) +datas += collect_data_files(package, subdir="profiles") diff --git a/PyInstaller hooks/hook-tabulator.py b/PyInstaller hooks/hook-tabulator.py new file mode 100644 index 000000000..c9e3560f5 --- /dev/null +++ b/PyInstaller hooks/hook-tabulator.py @@ -0,0 +1,5 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("tabulator") +datas += collect_data_files("tabulator", subdir="loaders", include_py_files=True) +datas += collect_data_files("tabulator", subdir="parsers", include_py_files=True) diff --git a/README.md b/README.md index f90b69cb8..4f93010bb 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ We provide three options for installing Spine Toolbox. The first two options als the Pre-installation steps**: - [Python/pipx](#installation-with-python-and-pipx) (we intend to make stable releases every month or so) - [From source files](#installation-from-sources-using-git) (this is the cutting edge - and more likely to have bugs) +- [Windows executable as .zip bundle](https://spine-tools.github.io/Downloads/) (requires only unzipping the downloaded .zip file) - [Windows installation package](#windows-64-bit-installer-package) (these are quite old - not recommended) ### Pre-installation @@ -124,6 +125,7 @@ or upgrade the *development* version with python -m pipx upgrade spinetoolbox-dev + ### Installation from sources using Git This option is for developers and other contributors who want to debug or edit Spine Toolbox source code. Once diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/compound_model_filter_accepts_model.py b/benchmarks/compound_model_filter_accepts_model.py new file mode 100644 index 000000000..bea0dedcd --- /dev/null +++ b/benchmarks/compound_model_filter_accepts_model.py @@ -0,0 +1,61 @@ +""" +This script benchmarks CompoundModelBase.filter_accepts_model(). +""" +import os +import sys + +if sys.platform == "win32" and "HOMEPATH" not in os.environ: + import pathlib + os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent) + +import time +from typing import Optional +import pyperf +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication +from benchmarks.utils import StdOutLogger +from spinetoolbox.spine_db_manager import SpineDBManager +from spinetoolbox.spine_db_editor.mvcmodels.compound_models import CompoundModelBase, CompoundParameterValueModel +from spinetoolbox.spine_db_editor.mvcmodels.single_models import SingleModelBase + + +def call_filter_accepts_model( + loops: int, compound_model: CompoundModelBase, single_model: SingleModelBase +) -> float: + duration = 0.0 + for _ in range(loops): + start = time.perf_counter() + compound_model.filter_accepts_model(single_model) + duration += time.perf_counter() - start + return duration + + +def run_benchmark(output_file: Optional[str]): + if not QApplication.instance(): + QApplication() + db_mngr = SpineDBManager(QSettings(), parent=None) + logger = StdOutLogger() + db_map = db_mngr.get_db_map("sqlite://", logger, create=True) + entity_class, error = db_map.add_entity_class_item(name="Object") + assert error is None + db_map.add_entity_class_item(name="Subject") + relationship_class, error = db_map.add_entity_class_item(name="Object__Subject", dimension_name_list=("Object", "Subject")) + assert error is None + compound_model = CompoundParameterValueModel(None, db_mngr, db_map) + compound_model.set_filter_class_ids({db_map: {entity_class["id"]}}) + single_model = SingleModelBase(compound_model, db_map, relationship_class["id"], committed=False) + runner = pyperf.Runner() + benchmark = runner.bench_time_func( + "CompoundModelBase.filter_accepts_model[filter by class ids]", + call_filter_accepts_model, + compound_model, + single_model + ) + if output_file: + pyperf.add_runs(output_file, benchmark) + db_mngr.close_all_sessions() + db_mngr.deleteLater() + + +if __name__ == "__main__": + run_benchmark(output_file="") diff --git a/benchmarks/db_mngr_get_icon_mngr.py b/benchmarks/db_mngr_get_icon_mngr.py new file mode 100644 index 000000000..8cc31ecfd --- /dev/null +++ b/benchmarks/db_mngr_get_icon_mngr.py @@ -0,0 +1,65 @@ +""" +This script benchmarks SpineDBManager.get_icon_mngr(). +""" +import os +import sys + +if sys.platform == "win32" and "HOMEPATH" not in os.environ: + import pathlib + os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent) + +import time +from typing import Optional, Iterable +import pyperf +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication +from spinetoolbox.spine_db_manager import SpineDBManager +from spinedb_api import DatabaseMapping +from spinetoolbox.spine_db_icon_manager import SpineDBIconManager + + +def db_mngr_get_icon_mngr( + loops: int, db_mngr: SpineDBManager, db_maps: Iterable[DatabaseMapping] +) -> float: + duration = 0.0 + for _ in range(loops): + for db_map in db_maps: + start = time.perf_counter() + icon_mngr = db_mngr.get_icon_mngr(db_map) + duration += time.perf_counter() - start + assert isinstance(icon_mngr, SpineDBIconManager) + return duration + + +def run_benchmark(output_file: Optional[str]): + if not QApplication.instance(): + QApplication() + db_mngr = SpineDBManager(QSettings(), parent=None) + inner_loops = 10 + db_maps = [DatabaseMapping("sqlite://", create=True) for _ in range(inner_loops)] + runner = pyperf.Runner() + benchmark = runner.bench_time_func( + "SpineDBManager.get_icon_mngr[always different db map]", + db_mngr_get_icon_mngr, + db_mngr, + db_maps, + inner_loops=inner_loops, + ) + if output_file: + pyperf.add_runs(output_file, benchmark) + db_maps = inner_loops * [DatabaseMapping("sqlite://", create=True)] + benchmark = runner.bench_time_func( + "SpineDBManager.get_icon_mngr[always same db_map]", + db_mngr_get_icon_mngr, + db_mngr, + db_maps, + inner_loops=inner_loops, + ) + if output_file: + pyperf.add_runs(output_file, benchmark) + db_mngr.close_all_sessions() + db_mngr.deleteLater() + + +if __name__ == "__main__": + run_benchmark(output_file="") diff --git a/benchmarks/db_mngr_get_item.py b/benchmarks/db_mngr_get_item.py new file mode 100644 index 000000000..c20dae680 --- /dev/null +++ b/benchmarks/db_mngr_get_item.py @@ -0,0 +1,63 @@ +""" +This script benchmarks SpineDBManager.get_item(). +""" +import os +import sys + +if sys.platform == "win32" and "HOMEPATH" not in os.environ: + import pathlib + os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent) + +import time +from typing import Optional, Sequence +import pyperf +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication +from spinedb_api import DatabaseMapping +from spinedb_api.temp_id import TempId +from spinetoolbox.spine_db_manager import SpineDBManager +from benchmarks.utils import StdOutLogger + + +def db_mngr_get_value( + loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, item_type, ids: Sequence[TempId] +) -> float: + duration = 0.0 + for _ in range(loops): + for id_ in ids: + start = time.perf_counter() + db_mngr.get_item(db_map, item_type, id_) + duration += time.perf_counter() - start + return duration + + +def run_benchmark(output_file: Optional[str]): + if not QApplication.instance(): + QApplication() + db_mngr = SpineDBManager(QSettings(), parent=None) + logger = StdOutLogger() + db_map = db_mngr.get_db_map("sqlite://", logger, create=True) + inner_loops = 10 + ids = [] + for i in range(inner_loops): + item, error = db_map.add_entity_class_item(name=str(i)) + assert error is None + ids.append(item["id"]) + runner = pyperf.Runner() + benchmark = runner.bench_time_func( + "SpineDBManager.get_value[parameter_value, DisplayRole]", + db_mngr_get_value, + db_mngr, + db_map, + "entity_class", + ids, + inner_loops=inner_loops, + ) + if output_file: + pyperf.add_runs(output_file, benchmark) + db_mngr.close_all_sessions() + db_mngr.deleteLater() + + +if __name__ == "__main__": + run_benchmark(output_file="") diff --git a/benchmarks/db_mngr_get_value.py b/benchmarks/db_mngr_get_value.py new file mode 100644 index 000000000..59358ccf1 --- /dev/null +++ b/benchmarks/db_mngr_get_value.py @@ -0,0 +1,75 @@ +""" +This script benchmarks SpineDBManager.get_value(). +""" +import os +import sys + +if sys.platform == "win32" and "HOMEPATH" not in os.environ: + import pathlib + os.environ["HOMEPATH"] = str(pathlib.Path(sys.executable).parent) + +import time +from typing import Optional, Sequence +import pyperf +from PySide6.QtCore import QSettings, Qt +from PySide6.QtWidgets import QApplication +from spinedb_api.db_mapping_base import PublicItem +from spinedb_api import DatabaseMapping, to_database +from spinetoolbox.spine_db_manager import SpineDBManager +from benchmarks.utils import StdOutLogger + + +def db_mngr_get_value( + loops: int, db_mngr: SpineDBManager, db_map: DatabaseMapping, items: Sequence[PublicItem], role: Qt.ItemDataRole +) -> float: + duration = 0.0 + for _ in range(loops): + for item in items: + start = time.perf_counter() + db_mngr.get_value(db_map, item, role) + duration += time.perf_counter() - start + return duration + + +def run_benchmark(output_file: Optional[str]): + if not QApplication.instance(): + QApplication() + db_mngr = SpineDBManager(QSettings(), parent=None) + logger = StdOutLogger() + db_map = db_mngr.get_db_map("sqlite://", logger, create=True) + db_map.add_entity_class_item(name="Object") + db_map.add_parameter_definition_item(name="x", entity_class_name="Object") + db_map.add_entity_item(name="object", entity_class_name="Object") + value_items = [] + for i in range(100): + alternative_name = str(i) + db_map.add_alternative_item(name=str(i)) + value, value_type = to_database(i) + item, error = db_map.add_parameter_value_item( + entity_class_name="Object", + parameter_definition_name="x", + entity_byname=("object",), + alternative_name=alternative_name, + value=value, + type=value_type, + ) + assert error is None + value_items.append(item) + runner = pyperf.Runner() + benchmark = runner.bench_time_func( + "SpineDBManager.get_value[parameter_value, DisplayRole]", + db_mngr_get_value, + db_mngr, + db_map, + value_items, + Qt.ItemDataRole.DisplayRole, + inner_loops=len(value_items), + ) + if output_file: + pyperf.add_runs(output_file, benchmark) + db_mngr.close_all_sessions() + db_mngr.deleteLater() + + +if __name__ == "__main__": + run_benchmark(output_file="") diff --git a/benchmarks/utils.py b/benchmarks/utils.py new file mode 100644 index 000000000..5060e7f7d --- /dev/null +++ b/benchmarks/utils.py @@ -0,0 +1,9 @@ +class _EmitPrinter: + @staticmethod + def emit(text): + print(text) + + +class StdOutLogger: + msg = _EmitPrinter() + msg_error = _EmitPrinter() diff --git a/bin/append_license.py b/bin/append_license.py index 70b873d57..9d68e7b94 100755 --- a/bin/append_license.py +++ b/bin/append_license.py @@ -6,6 +6,7 @@ license_text = [ "######################################################################################################################\n", "# Copyright (C) 2017-2022 Spine project consortium\n", +"# Copyright Spine Toolbox contributors\n", "# This file is part of Spine Toolbox.\n", "# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General\n", "# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)\n", diff --git a/bin/update_copyrights.py b/bin/update_copyrights.py deleted file mode 100644 index e41a6a39e..000000000 --- a/bin/update_copyrights.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python - -from pathlib import Path -import time - - -current_year = time.gmtime().tm_year -root_dir = Path(__file__).parent.parent -project_source_dir = Path(root_dir, "spinetoolbox") -test_source_dir = Path(root_dir, "tests") - -expected = f"# Copyright (C) 2017-{current_year} Spine project consortium" - - -def update_copyrights(path, suffix, recursive=True): - for path in path.iterdir(): - if path.suffix == suffix: - i = 0 - with open(path) as python_file: - lines = python_file.readlines() - for i, line in enumerate(lines[1:4]): - if line.startswith("# Copyright (C) "): - lines[i + 1] = lines[i + 1][:21] + str(current_year) + lines[i + 1][25:] - break - if len(lines) <= i + 1 or not lines[i + 1].startswith(expected): - print(f"Confusing or no copyright: {path}") - else: - with open(path, "w") as python_file: - python_file.writelines(lines) - elif recursive and path.is_dir(): - update_copyrights(path, suffix) - - -update_copyrights(root_dir, ".py", recursive=False) -update_copyrights(project_source_dir, ".py") -update_copyrights(project_source_dir, ".ui") -update_copyrights(test_source_dir, ".py") - -print("Done. Don't forget to update append_license.py!") diff --git a/build_utils/path.pth b/build_utils/path.pth deleted file mode 100644 index 255e72c98..000000000 --- a/build_utils/path.pth +++ /dev/null @@ -1,3 +0,0 @@ -lib -lib/library.zip -python37.dll diff --git a/build_utils/pyvenv.cfg b/build_utils/pyvenv.cfg deleted file mode 100644 index 40b0cc6da..000000000 --- a/build_utils/pyvenv.cfg +++ /dev/null @@ -1 +0,0 @@ -include-system-site-packages = false diff --git a/build_utils/sitecustomize.py b/build_utils/sitecustomize.py deleted file mode 100644 index ac08930ad..000000000 --- a/build_utils/sitecustomize.py +++ /dev/null @@ -1,21 +0,0 @@ -"""This file modifies the site packages of the embedded python interpreter -in tools/python.exe. It removes two paths from PYTHONPATH and adds -library.zip into it's site-packages.""" - -import site -import os -import sys - -p = sys.path -try: - p.remove("C:\\Python37\\Lib") -except ValueError: - pass -try: - p.remove("C:\\Python37\\DLLs") -except ValueError: - pass -sys.path = p -python_exe_dir, _ = os.path.split(sys.executable) -site.addsitedir(os.path.join(python_exe_dir, os.pardir, "lib")) -site.addsitedir(os.path.join(python_exe_dir, os.pardir, "lib", "library.zip")) diff --git a/cx_Freeze_setup.py b/cx_Freeze_setup.py index b8a76e385..09bf8888a 100644 --- a/cx_Freeze_setup.py +++ b/cx_Freeze_setup.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -12,122 +13,100 @@ """ cx-Freeze setup file for Spine Toolbox. -Requires: Python3.7-64bit. cx_Freeze 6.6. - -NOTE: This file is for release-0.6 branch. - -To make a Spine Toolbox installation bundle, follow these steps: - On Windows: -1. Build application with command 'python cx_Freeze_setup.py build' -2. Check version numbers and CHANGELOG -3. Compile setup.iss file with Inno Setup. This will create a single-file (.exe) installer. +1. Download *embeddable* Python packages from https://www.python.org/downloads/windows/ +2. Unzip the downloaded package somewhere. +3. Build application with command 'python cx_Freeze_setup.py build --embedded-python=' +4. Check version numbers and CHANGELOG -On other platforms (not tested): -1. Build the application into build/ directory with command 'python cx_Freeze_setup.py build' -2. Use cx_Freeze_setup.py (this file) and Cx_Freeze (see Cx_Freeze documentation for help) """ - -import os +import argparse +from pathlib import Path import sys from cx_Freeze import setup, Executable -from spinetoolbox.config import APPLICATION_PATH - -version = {} -with open("spinetoolbox/version.py") as fp: - exec(fp.read(), version) +from cx_Freeze.cli import prepare_parser +import ijson +import spinedb_api +import spine_engine +from spine_engine.config import BUNDLE_DIR +import spine_items -def main(argv): +def main(): """Main of cx_Freeze_setup.py.""" - python_dir, python_exe = os.path.split(sys.executable) - python37_dll = os.path.join(python_dir, "python37.dll") - os.environ['TCL_LIBRARY'] = os.path.join(python_dir, "tcl", "tcl8.6") - os.environ['TK_LIBRARY'] = os.path.join(python_dir, "tcl", "tk8.6") - # tcl86t.dll and tk86t.dll are required by tkinter, which in turn is required by matplotlib - tcl86t_dll = os.path.join(python_dir, "DLLs", "tcl86t.dll") - tk86t_dll = os.path.join(python_dir, "DLLs", "tk86t.dll") - # Path to built documentation (No need for sources) - doc_path = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "docs", "build")) - # Paths to files that should be included as is (changelog, readme, licence files, alembic version files) - changelog_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "CHANGELOG.md")) - readme_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "README.md")) - copying_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "COPYING")) - copying_lesser_file = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "COPYING.LESSER")) - alembic_version_files = alembic_files(python_dir) - pyvenv_cfg = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "pyvenv.cfg")) - path_pth = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "path.pth")) - site_customize = os.path.abspath(os.path.join(APPLICATION_PATH, os.path.pardir, "build_utils", "sitecustomize.py")) + parser = argparse.ArgumentParser(parents=[prepare_parser()], add_help=False) + parser.add_argument("--embedded-python", help="path to embedded Python interpreter", required=True) + options = parser.parse_args() + sys.argv = [arg for arg in sys.argv if not arg.startswith("--embedded-python")] + spinedb_api_package = Path(spinedb_api.__file__).parent + spinedb_api_root = spinedb_api_package.parent + spine_engine_root = Path(spine_engine.__file__).parent.parent + spine_items_root = Path(spine_items.__file__).parent.parent + root = Path(__file__).parent + changelog_file = root / "CHANGELOG.md" + readme_file = root / "README.md" + copying_file = root / "COPYING" + copying_lesser_file = root / "COPYING.LESSER" # Most dependencies are automatically detected but some need to be manually included. build_exe_options = { - "packages": ["packaging", "pkg_resources", "spine_engine", "spine_items", "spinedb_api"], + "path": list(set(map(str, (spinedb_api_root, spine_engine_root, spine_items_root)))) + sys.path, + "packages": ["ijson.compat", "pendulum.locales", "sqlalchemy.sql.default_comparator", "tabulator.loaders", "tabulator.parsers"], "excludes": [], - "includes": [ - "atexit", - "pygments.lexers.markup", - "pygments.lexers.python", - "pygments.lexers.shell", - "pygments.lexers.julia", - "pygments.styles.default", - "qtconsole.client", - "sqlalchemy.sql.default_comparator", - "sqlalchemy.ext.baked", - "ijson.compat", - "ijson.backends.__init__", - "ijson.backends.python", - "ijson.backends.yajl", - "ijson.backends.yajl2", - "ijson.backends.yajl2_c", - "ijson.backends.yajl2_cffi", - ], + "includes": [], "include_files": [ - (doc_path, "docs/"), - tcl86t_dll, - tk86t_dll, changelog_file, readme_file, copying_file, copying_lesser_file, - (sys.executable, os.path.join("tools/", python_exe)), - (python37_dll, os.path.join("tools/", "python37.dll")), - pyvenv_cfg, - path_pth, - site_customize, ] - + alembic_version_files, - "include_msvcr": True + + alembic_files(spinedb_api_package) + + spine_repl_files() + + ijson_backends() + + embedded_python(options), } # Windows specific options - if os.name == "nt": # Windows specific options + if sys.platform == "win32": # Windows specific options base = "Win32GUI" # set this to "Win32GUI" to not show console, "Console" shows console else: # Other platforms base = None executables = [Executable("spinetoolbox.py", base=base, icon="spinetoolbox/ui/resources/app.ico")] setup( - name="Spine Toolbox", - version=version["__version__"], - description="An application to define, manage, and execute various energy system simulation models.", - author="Spine project consortium", options={"build_exe": build_exe_options}, executables=executables, ) + return 0 + +def embedded_python(options): + embedded_python_path = Path(options.embedded_python) + return [(str(path), str(Path(BUNDLE_DIR, path.name))) for path in embedded_python_path.iterdir()] -def alembic_files(python_dir): + +def alembic_files(spinedb_api_path): """Returns a list of tuples of files in python/Lib/site-packages/spinedb_api/alembic/versions. First item in tuple is the source file. Second item is the relative destination path to the install directory. We are including these .py files into 'include_files' list because adding them to the 'includes' list would require us to give the whole explicit file name. """ - dest_dir = os.path.join("lib", "spinedb_api", "alembic", "versions") - p = os.path.join(python_dir, "Lib", "site-packages", "spinedb_api", "alembic", "versions") - files = list() - for f in os.listdir(p): - if f.endswith(".py"): - files.append((os.path.abspath(os.path.join(p, f)), os.path.join(dest_dir, f))) - return files + source_dir = spinedb_api_path / "alembic" / "versions" + destination_dir = Path("lib", "spinedb_api", "alembic", "versions") + return [(str(file), str(destination_dir / file.name)) for file in source_dir.iterdir() if file.suffix == ".py"] + + +def ijson_backends(): + source_dir = Path(ijson.__file__).parent / "backends" + destination_dir = Path("lib", "ijson", "backends") + return [(str(file), str(destination_dir / file.name)) for file in source_dir.iterdir() if file.suffix == ".py"] + + +def spine_repl_files(): + # spine_repl.jl gets copied to the bundle automatically + py_repl = Path("execution_managers", "spine_repl.py") + source_file = Path(spine_engine.__file__).parent / py_repl + destination_file = Path("lib", spine_engine.__name__) / py_repl + return [(str(source_file), str(destination_file))] if __name__ == '__main__': - sys.exit(main(sys.argv)) + sys.exit(main()) diff --git a/dev-requirements.txt b/dev-requirements.txt index 8a575050c..b4c6d6865 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -7,4 +7,6 @@ setuptools_scm[toml] >= 6.2 build wheel >=0.36.2 twine >= 3.4.1 +coverage[toml] +pyperf -r "docs/requirements.txt" diff --git a/docs/source/data_import_export.rst b/docs/source/data_import_export.rst index 6d115d4a9..2893026e0 100644 --- a/docs/source/data_import_export.rst +++ b/docs/source/data_import_export.rst @@ -69,53 +69,48 @@ question so it is easier to visualize how the importer is handling the data. Importer specification editor ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The left side of the **Importer Specification Editor** contains four dock widgets that deal with the input source data. -**Source files** contains the filepath to the current input file. -**Source tables** contains the different tables the file has or that user has set up. -**Source options** has a few options on how to handle the incoming data. -The available options depend on the connector type. -**Source data** allows for previewing of the data selected in **Source files** -and also of the currently selected mapping. - -The right side of the spec editor shows import mappings for the table selected in **Source tables**. -**Mappings** lets you add, duplicate and remove mappings or -select a mapping to modify its options. **Mapping options** allow you to specify things like what item type -you are importing. In **Mapping specification** you can select the specific places in the source data where the -object names, values etc. will be taken from. +In the upper part of the specification editor, the name and description for the specification can be set. +Underneath, the filepath is shown and it is allowed to br modified. Next to the filepath the used connector type +is shown. On the left side, the different tables the file has or that user has set up are shown. Next to that +there are the connector specific options and underneath that a preview of the selected table is shown. The items in +the table are highlighted according to the selected mapping. The mappings are listed on the right side of the editor. +underneath, the to be imported item types can be specified and other options set. Below that +you can select the specific places in the source data where the entity names, values etc. will be taken from. .. image:: img/import_editor_window.png :align: center -All tables available in the file selected in **Source files** are listed in **Source tables**. +All tables available in the file selected in **File path** are listed in the leftmost dock widget. If the file does not have tables or the file type does not support them (e.g. CSV), all of the file's data will be in a single table called 'data'. The tables can be selected and deselected using the check boxes and only the selected ones will be imported. The option *Select All* is useful for selecting or deselecting all tables. -If the Importer is opened in `anonymous mode`_, there is also the option to add tables. +If the Importer is opened in `anonymous mode`_, there is also the option to add tables .. tip:: Multiple CSV files can be bundled into a *datapackage* which uses its own connector in Importer. Specifically, each CSV file in the datapackage shows up as a separate table in **Source tables**. See :ref:`Setting up datapackages in Links` for more information on how to pack CSVs into a datapackage automatically within your workflow. -**Source options** contains options to "format" the incoming data. The available options can differ depending on the -selected connector. The above picture shows the available options for Excel files. **Max rows** specifies the amount of -rows from the input data that are considered by the Importer. The option **Has header** converts the first row into -headers. **Skip rows** and **Skip columns** skip the first *N* specified rows or columns from the table. -If the table has empty rows or columns and some other data after that that you don't want to use, -**Read until empty row/column on first column/row** options can be used to "crop" the imported data to the -first relevant block of information. Other possible options for different connector types include -**Encoding**, **Delimiter**, **Custom Delimiter**, **Quotechar** and **Maximum Depth**. -**Load default mapping** sets all of the selections in the spec editor -to their default values. Be careful not to press this button unless you want to wipe the whole specification clean. +Next to the table dock widget, there is a small dock widget that allows to "format" the incoming data. +The available options can differ depending on the selected connector. The above picture shows the available +options for Excel files. **Max rows** specifies the amount of rows from the input data that are considered +by the Importer. The option **Has header** converts the first row into headers. **Skip rows** and **Skip columns** +skip the first *N* specified rows or columns from the table. If the table has empty rows or columns and some +other data after that that you don't want to use, **Read until empty row/column on first column/row** options +can be used to "crop" the imported data to the first relevant block of information. Other possible options for +different connector types include **Encoding**, **Delimiter**, **Custom Delimiter**, **Quotechar** and +**Maximum Depth**. **Load default mapping** sets all of the selections in the spec editor to their default values. +Be careful not to press this button unless you want to wipe the whole specification clean. .. note:: If you are working on a specification and accidentally press the *load default mapping* button you can undo previous changes for the specification from the hamburger menu or by pressing **Ctrl+Z**. To redo actions, or press **Crl+Y**. -**Source data** shows the selected table's data and a preview of how the selected mapping will import the data. -An important aspect of data import is whether each item in the input data should be read as a string, a number, +When a table is selected, it's data and a preview of how the selected mapping will +import the data will be presented under the options dock widget. An important aspect of data import is +whether each item in the input data should be read as a string, a number, a time stamp or something else. By default all input data is read as strings. However, more often than not things like parameter values are actually numbers. Though types are usually casted automatically, it is possible to manually control what type of data each column (and sometimes each row) contains from the preview table. @@ -127,26 +122,27 @@ Right clicking the column/row header also gives the opportunity to change the da Under **Mappings** you can manage mappings by adding new ones and removing or duplicating existing ones. Each table has it's own mappings and every mapping has its own options. In **Mappings** you can select the mapping -that you want to start modifying. Having multiple mappings for a single table allows to for example import relationship -classes and object classes at the same time from a single table in a file. - -**Mapping options** helps the importer get a feel for what kind of data it will be importing. -The available *item type* options are *Object class, Relationship class, Object group, Alternative, Scenario, -Scenario alternative, Parameter value list, Feature, Tool, Tool feature* and *Tool feature method*. The other available -options are dependent on the Item type. *Import objects* allows to import objects alongside relationships -or object groups. *Parameter type* is used to specify what type of parameters, if any, the sheet contains. It has options +that you want to start modifying. Having multiple mappings for a single table allows to for example import +multiple item types at the same time from a single table in a file. + +Underneath **Mappings** there are options that help the importer get a feel for what kind of data it will be importing. +The available *item type* options are *Entity class, Entity group, Alternative, Scenario, +Scenario alternative* and *Parameter value list*. The other available +options are dependent on the Item type. *Import entities* allows to import entities alongside +or entity groups. *Parameter type* is used to specify what type of parameters, if any, the sheet contains. It has options *Value, Definition* and *None*. If *Value* or *Definition* is selected the value or respectively the default value type can be set from the drop-down list. *Use before alternative* is only available for *Scenario alternative* -item type. *Read data from row* lets you specify the row where the importer starts to read the data. *Ignore columns* allow you to select individual columns that you want to exclude from the -whole importing process. *Number of dimensions* sets the amount of dimensions the relationship to be imported has. +whole importing process. *Number of dimensions* sets the amount of dimensions the entity to be imported has. *Repeat time series* sets the repeat flag to true when importing time series. *Map dimensions* sets the -number of map indexes when importing map values. +number of map indexes when importing map values. *Use before alternative* maps scenario before alternatives when +importing scenario alternatives. *Compress Maps* can be used to compress value maps. -Once everything in **Mapping options** is in order, the next step is to set the mapping specification. -**Mapping specification** is the part where the decisions are made on how the input data is interpreted: +Once everything in the before mentioned options is in order, the next step is to set the mapping specification. +Below the options there is the part where the decisions are made on how the input data is interpreted: which row or column contains the entity class names, parameter values, time stamps and so on. -The **Mapping specification** dock widget contains all of the targets that the selected mapping options specify. +The dock widget contains all of the targets that the selected mapping options specify. Each target has a *Source reference* and a *Source type*. *Source type* specifies if the data for the target is coming in the form of a column, row, table name etc. In the *Source ref.* section you can pinpoint to the exact row, column etc. to use as the data. The *Filter* section can be used to further specify which values to @@ -172,12 +168,12 @@ opening the spec editor from Toolbox **Main Toolbar**. .. image:: img/importer_spec_editor_anonymous_mode.png :align: center -In anonymous mode new tables can be created in **Source tables** by double clicking ** -and writing in a name for the new table. The **Source data** will contain an infinite grid of cells on which you +In anonymous mode new tables can be created by double clicking ** +and writing in a name for the new table. The preview will show an infinite grid of cells on which you can create different mappings. .. note:: You can exit the Anonymous mode by browsing to and selecting an existing file using the controls in - **Source files** dock. + *File path*. Exporting data with Exporter **************************** @@ -192,8 +188,8 @@ By default data is mapped to columns but it is also possible to create pivot tab Exporter also uses specifications so the same configurations can be reused by other exporters even in other projects. -The specification can be edited in *Exporter specification editor* -which is accessible by the |wrench| button in the item's Properties dock +The specification can be edited in **Exporter specification editor** +which is accessible by the |wrench| button in the item's **Properties** dock or by double clicking Exporter's icon on the **Design View**. A specification that is not associated with any specific Exporter project item can be created and edited from the Main toolbar. @@ -201,14 +197,14 @@ and edited from the Main toolbar. Properties dock ~~~~~~~~~~~~~~~ -Exporter's Properties dock controls project item specific settings +Exporter's **Properties** dock controls project item specific settings that are not part of the item's specification. .. image:: img/exporter_properties.png :align: center Specification used by the active Exporter item can be selected from the *Specification* combobox. -The |wrench| button opens *Exporter specification editor* +The |wrench| button opens **Exporter specification editor** where it is possible to edit the specification. Data Stores that are connected to the exporter and are available for export are listed below @@ -291,18 +287,18 @@ The currently selected mapping is edited using the controls in *Mapping options* The *Mapping options* dock contains controls that apply to the mapping as a whole, e.g. what data the output tables contain. It is important to choose *Item type* correctly since it determines what database items the mapping outputs and also dictates the mapping types that will be visible in the *Mapping specification* dock widget. It has options -*Object class, Relationship class, Relationship class with object parameter, Object group, Alternative, Scenario, -Scenario alternative, Parameter value list, Feature, Tool, Tool feature* and *Tool feature method*. The rest of the +*Entity class, Entity class with dimension parameter, Entity group, Alternative, Scenario, +Scenario alternative* and *Parameter value list*. The rest of the options besides *Group function* are item type specific and may not be available for all selections. .. image:: img/exporter_mapping_options_dock.png :align: center Checking the *Always export header* checkbox outputs a table that has fixed headers even if the table is -otherwise empty. If *Item type* is Relationship class, the *Relationship dimensions* spinbox can be used -to specify the maximum number of relationships' dimensions that the mapping is able to handle. -*Selected dimensions* option is only available for the *Relationship class with object parameter* item -type and it is used to specify the relationship dimension where the object parameters are selected from. +otherwise empty. If *Item type* is Entity class, the *Entity dimensions* spinbox can be used +to specify the maximum number of entity's dimensions that the mapping is able to handle. +*Selected dimensions* option is only available for the *Entity class with dimension parameter* item +type and it is used to specify the entity dimension where the entity parameters are selected from. Parameters can be outputted by choosing their value type using the *Parameter type* combobox. The *Value* choice adds rows to *Mapping specification* for parameter values associated with individual entities while *Default value* allows outputting parameters' default values. The maximum number of value dimensions in @@ -322,7 +318,7 @@ Mapping specification *Mapping specification* contains a table which defines the structure of the mapping's output tables. Like mentioned before, the contents of the table depends on choices on *Mapping options*, e.g. the item type, parameter type or dimensions. -Each row corresponds to an item in the database: object class names, object names, parameter values etc. +Each row corresponds to an item in the database: entity class names, entity names, parameter values etc. The item's name is given in the *Mapping type* column. The colors help to identify the corresponding elements in the preview. @@ -331,16 +327,16 @@ that is, where the item is written or otherwise used when the output tables are By default, a plain integral number in this column means that the item is written to that column in the output table. From the other choices, *hidden* means that the item will not show on the output. *Table name*, on the other hand, uses the item as output table names. -For example, outputting object classes as table names will generate one new table for every object class +For example, outputting entity classes as table names will generate one new table for every entity class in the database, each named after the class. -Each table in turn will contain the parameters and objects of the table's object class. +Each table in turn will contain the parameters and entities of the table's entity class. If multiple mappings generate a table with a common name then each mapping appends to the same table in the order specified by the *Write order* column on *Mappings* dock. The *column header* position makes the item a column header for a **buddy item**. Buddy items have some kind of logical relationship with their column header, -for instance the buddy of an object class is its objects; -setting the object class to *column header* will write the name of the class as the objects' column header. +for instance the buddy of an entity class is its entities; +setting the entity class to *column header* will write the name of the class as the entity's column header. .. note:: Currently, buddies are fixed and defined only for a small set database items. @@ -357,12 +353,12 @@ They then act as a pivot header for the data item which is the last non-hidden i Once checked as pivoted, an item's position column defines a pivot header row instead of output column. By default a row ends up in the output table only when all mapping items yield some data. -For example, when exporting object classes and objects, only classes that have objects get written to output. -However, sometimes it is useful to export 'empty' object classes as well. +For example, when exporting entity classes and entities, only classes that have entities get written to output. +However, sometimes it is useful to export 'empty' entity classes as well. For this purpose a mapping can be set as **nullable** in the *Nullable* column. -Continuing the example, checking the *Nullable* checkbox for *Objects* would produce an output table with -all object classes including ones without objects. -The position where objects would normally be outputted are left empty for those classes. +Continuing the example, checking the *Nullable* checkbox for *Entities* would produce an output table with +all entity classes including ones without entities. +The position where entities would normally be outputted are left empty for those classes. Besides the *column header* position it is possible to give fixed column headers to items using the *Header* column in *Mapping specification* dock. @@ -457,11 +453,11 @@ for these applications. *Single item* Writing the item's name to the field filters out all other items. - For example, to output the object class called 'node' only, write :literal:`node` to the *Filter* field. + For example, to output the entity class called 'node' only, write :literal:`node` to the *Filter* field. *OR operator* The vertical bar :literal:`|` serves as the OR operator. - :literal:`node|unit` as a filter for object classes would output classes named 'node' and 'unit'. + :literal:`node|unit` as a filter for entity classes would output classes named 'node' and 'unit'. *Excluding an item* While perhaps not the most suitable task for regular expressions it is still possible to 'negate' a filter. diff --git a/docs/source/executing_projects.rst b/docs/source/executing_projects.rst index 65d142278..272fb0d8c 100644 --- a/docs/source/executing_projects.rst +++ b/docs/source/executing_projects.rst @@ -16,12 +16,12 @@ Executing Projects This section describes how executing a project works and what resources are passed between project items at execution time. The buttons used to control executions are located in the **Toolbar**'s Execute -section. -Execution happens by either pressing the **Project** button (|play-all|) to execute the -whole project, or by pressing the **Selection** button (|play-selected|) to only execute selected items. +Execution happens by either pressing the *Execute Project* button (|play-all|) to execute the +whole project, or by pressing the *Execute Selected* button (|play-selected|) to only execute selected items. Next to these buttons is the **Stop** button (|stop|), which can be used to stop an ongoing execution. A project consists of project items and connections (yellow arrows) that are visualized on the **Design View**. You use the project items and the connections to build a **Directed Acyclic Graph (DAG)**, with the project -items as *nodes* and the connections as *edges*. A DAG is traversed using the **breadth-first-search** algorithm. +items as *nodes* and the connections as *edges*. The DAG is traversed using the **breadth-first-search** algorithm. Rules of DAGs: @@ -69,8 +69,7 @@ The image below shows such a DAG where the items form a loop. You can also execute only the selected parts of a project by multi-selecting the items you want to execute and pressing the |play-selected| button in the tool bar. For example, to execute only items -*b*, *d* and *f*, select the items in **Design View** or in the project item list in **Project** dock -widget and then press the |play-selected| button. +*b*, *d* and *f*, select the items in **Design View** |play-selected| button. .. tip:: You can select multiple project items by holding the **Ctrl** key down and clicking on diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index f3ef05a67..4385ed198 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -50,9 +50,6 @@ The central element in Spine Toolbox's interface is the **Design View**, which allows you to visualize and manipulate your project workflow. In addition to the **Design View** there are a few `dock widgets` that provide additional functionality: -* **Project** provides a more concise view of your project, including the *Items* that are currently in the - project, grouped by category: Data Stores, Data Connections, Tools, Views, Importers, Exporters and - Manipulators. * **Properties** provides an interface to interact with the currently selected project item. * **Event Log** shows relevant messages about user performed actions and the status of executions. * **Console** allows Spine Toolbox to execute Tools written in Python, Julia or GAMS and provides an interface to @@ -166,8 +163,7 @@ To add a Tool item drag-and-drop the Tool icon |tool_icon| from the **Toolbar** The **Add Tool** form will popup. Change name of the Tool to 'say hello world', and select 'hello_world' from the dropdown list just below, and click **Ok**. Now you should see the newly added Tool item as an icon in the -**Design View**, and also as an entry in the **Project** dock widget, under the 'Tools' category. It -should look similar to this: +**Design View**. It should look similar to this: .. image:: img/getting_started_first_tool_created.png :align: center @@ -180,7 +176,9 @@ selected from the dropdown list. .. note:: The Tool specification is now saved to disk but the project itself is not. Remember to save the project every once in a while when you are working. You can do this by selecting **File -> Save project** - from the main window or by pressing **Ctrl+S** when the main window is active. + from the main window or by pressing **Ctrl+S** when the main window is active. If the project is in such a state + that it has unsaved changes, an asterisk `*` is visible after the project name and path in the upper left corner + of the main window. Executing a Tool ---------------- @@ -220,8 +218,7 @@ To make things more interesting, we will now specify an *input file* for our 'he **Data Store**, **Exporter**, and **Data Transformer** project items connected to its input. Open the Tool specification editor for the 'hello world' Tool spec. You can do this for example, by double-clicking -the 'say hello world' Tool in **Design View**, or by right clicking the 'say hello world' -item in the **Project** dock -widget and selecting **Specification... -> Edit specification**, or from the **Tool Properties** by clicking the +the 'say hello world' Tool in **Design View**, or from the **Tool Properties** by clicking the Tool specification options button (|wrench|) next to the specification and selecting **Edit specification**. In **Input & Output files** dock widget, click the |plus| button next to the `Input Files` text. A dialog appears, @@ -268,13 +265,12 @@ Let's add a Data Connection item to our project, so that we're able to pass the onto the **Design View**. The *Add Data Connection* form will show up. Type 'pass input txt' in the name field and click **Ok**. The newly -added Data Connection item is now in the **Design View**, and also as an entry in the **Project** dock widgets items list, -under the 'Data Connections' category. +added Data Connection item is now in the **Design View**. Adding Data Files to a Data Connection -------------------------------------- -Select the 'pass input txt' Data Connection item to view its properties in the *Properties* dock widget. It should look +Select the 'pass input txt' Data Connection item to view its properties in the **Properties** dock widget. It should look similar to this: .. image:: img/getting_started_dc_properties.png diff --git a/docs/source/img/add_discharge_water_to_from_node.png b/docs/source/img/add_discharge_water_to_from_node.png deleted file mode 100644 index b09776e2b..000000000 Binary files a/docs/source/img/add_discharge_water_to_from_node.png and /dev/null differ diff --git a/docs/source/img/add_electricity_load_from_node.png b/docs/source/img/add_electricity_load_from_node.png deleted file mode 100644 index 39c75033a..000000000 Binary files a/docs/source/img/add_electricity_load_from_node.png and /dev/null differ diff --git a/docs/source/img/add_node_commodity.png b/docs/source/img/add_node_commodity.png deleted file mode 100644 index 32d71c663..000000000 Binary files a/docs/source/img/add_node_commodity.png and /dev/null differ diff --git a/docs/source/img/add_node_temporal_block.png b/docs/source/img/add_node_temporal_block.png deleted file mode 100644 index c66250c1a..000000000 Binary files a/docs/source/img/add_node_temporal_block.png and /dev/null differ diff --git a/docs/source/img/add_power_plant_units.png b/docs/source/img/add_power_plant_units.png deleted file mode 100644 index 6977692d9..000000000 Binary files a/docs/source/img/add_power_plant_units.png and /dev/null differ diff --git a/docs/source/img/add_pwr_plant_electricity_to_node.png b/docs/source/img/add_pwr_plant_electricity_to_node.png deleted file mode 100644 index e8153c1f6..000000000 Binary files a/docs/source/img/add_pwr_plant_electricity_to_node.png and /dev/null differ diff --git a/docs/source/img/add_pwr_plant_water_from_node.png b/docs/source/img/add_pwr_plant_water_from_node.png deleted file mode 100644 index c070f423d..000000000 Binary files a/docs/source/img/add_pwr_plant_water_from_node.png and /dev/null differ diff --git a/docs/source/img/add_pwr_plant_water_to_node.png b/docs/source/img/add_pwr_plant_water_to_node.png deleted file mode 100644 index 6921e504e..000000000 Binary files a/docs/source/img/add_pwr_plant_water_to_node.png and /dev/null differ diff --git a/docs/source/img/add_rsrv_water_to_from_node.png b/docs/source/img/add_rsrv_water_to_from_node.png deleted file mode 100644 index f9c6a5ca0..000000000 Binary files a/docs/source/img/add_rsrv_water_to_from_node.png and /dev/null differ diff --git a/docs/source/img/add_spillway_water_to_from_node.png b/docs/source/img/add_spillway_water_to_from_node.png deleted file mode 100644 index 0ae585564..000000000 Binary files a/docs/source/img/add_spillway_water_to_from_node.png and /dev/null differ diff --git a/docs/source/img/add_storage_commodity.png b/docs/source/img/add_storage_commodity.png deleted file mode 100644 index 9cbaa6f20..000000000 Binary files a/docs/source/img/add_storage_commodity.png and /dev/null differ diff --git a/docs/source/img/add_storage_unit.png b/docs/source/img/add_storage_unit.png deleted file mode 100644 index 80da768bb..000000000 Binary files a/docs/source/img/add_storage_unit.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_item_connections.png b/docs/source/img/case_study_a5_item_connections.png deleted file mode 100644 index fd5c2d70c..000000000 Binary files a/docs/source/img/case_study_a5_item_connections.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_model_parameters.png b/docs/source/img/case_study_a5_model_parameters.png deleted file mode 100644 index 5ade47477..000000000 Binary files a/docs/source/img/case_study_a5_model_parameters.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_output.png b/docs/source/img/case_study_a5_output.png deleted file mode 100644 index a719d7ebb..000000000 Binary files a/docs/source/img/case_study_a5_output.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png b/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png deleted file mode 100644 index c9116907b..000000000 Binary files a/docs/source/img/case_study_a5_output_electricity_load_unit_flow.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_schematic.png b/docs/source/img/case_study_a5_schematic.png deleted file mode 100644 index 6a3a9f8fa..000000000 Binary files a/docs/source/img/case_study_a5_schematic.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_spine_db_editor_empty.png b/docs/source/img/case_study_a5_spine_db_editor_empty.png deleted file mode 100644 index 1beb57bd1..000000000 Binary files a/docs/source/img/case_study_a5_spine_db_editor_empty.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_spine_opt_tool_properties.png b/docs/source/img/case_study_a5_spine_opt_tool_properties.png deleted file mode 100644 index 7cfd2d863..000000000 Binary files a/docs/source/img/case_study_a5_spine_opt_tool_properties.png and /dev/null differ diff --git a/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png b/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png deleted file mode 100644 index 410797290..000000000 Binary files a/docs/source/img/case_study_a5_spine_opt_tool_properties_cmdline_args.png and /dev/null differ diff --git a/docs/source/img/edit_tool_specification_new_program_file.png b/docs/source/img/edit_tool_specification_new_program_file.png deleted file mode 100644 index 5d5f0f72d..000000000 Binary files a/docs/source/img/edit_tool_specification_new_program_file.png and /dev/null differ diff --git a/docs/source/img/edit_tool_specification_spine_opt.png b/docs/source/img/edit_tool_specification_spine_opt.png deleted file mode 100644 index 1f9934261..000000000 Binary files a/docs/source/img/edit_tool_specification_spine_opt.png and /dev/null differ diff --git a/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png b/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png deleted file mode 100644 index cb86aae91..000000000 Binary files a/docs/source/img/gdx_export_parameter_indexing_window_using_existing_domain.png and /dev/null differ diff --git a/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png b/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png deleted file mode 100644 index 0ae7bf434..000000000 Binary files a/docs/source/img/gdx_export_parameter_indexing_window_using_new_domain.png and /dev/null differ diff --git a/docs/source/img/gdx_export_settings_window.png b/docs/source/img/gdx_export_settings_window.png deleted file mode 100644 index 4fd6426a9..000000000 Binary files a/docs/source/img/gdx_export_settings_window.png and /dev/null differ diff --git a/docs/source/img/gdx_exporter_properties.png b/docs/source/img/gdx_exporter_properties.png deleted file mode 100644 index 84122b049..000000000 Binary files a/docs/source/img/gdx_exporter_properties.png and /dev/null differ diff --git a/docs/source/img/import_editor_mapping_options.png b/docs/source/img/import_editor_mapping_options.png deleted file mode 100644 index 8dd1bab5e..000000000 Binary files a/docs/source/img/import_editor_mapping_options.png and /dev/null differ diff --git a/docs/source/img/import_editor_preview_table_mapping_menu.png b/docs/source/img/import_editor_preview_table_mapping_menu.png index 0c94820eb..c0f6185db 100644 Binary files a/docs/source/img/import_editor_preview_table_mapping_menu.png and b/docs/source/img/import_editor_preview_table_mapping_menu.png differ diff --git a/docs/source/img/import_editor_window.png b/docs/source/img/import_editor_window.png index 724d5cc6c..96bb69fed 100644 Binary files a/docs/source/img/import_editor_window.png and b/docs/source/img/import_editor_window.png differ diff --git a/docs/source/img/importer_spec_editor_anonymous_mode.png b/docs/source/img/importer_spec_editor_anonymous_mode.png index a5a11d4ac..ba4a52d6e 100644 Binary files a/docs/source/img/importer_spec_editor_anonymous_mode.png and b/docs/source/img/importer_spec_editor_anonymous_mode.png differ diff --git a/docs/source/img/install_spine_opt_plugin.png b/docs/source/img/install_spine_opt_plugin.png deleted file mode 100644 index 30f89dd0b..000000000 Binary files a/docs/source/img/install_spine_opt_plugin.png and /dev/null differ diff --git a/docs/source/img/items_connections.png b/docs/source/img/items_connections.png deleted file mode 100644 index c8f402194..000000000 Binary files a/docs/source/img/items_connections.png and /dev/null differ diff --git a/docs/source/img/main_window_new_project_with_project_items.png b/docs/source/img/main_window_new_project_with_project_items.png deleted file mode 100644 index ed5b17f82..000000000 Binary files a/docs/source/img/main_window_new_project_with_project_items.png and /dev/null differ diff --git a/docs/source/img/main_window_no_project.png b/docs/source/img/main_window_no_project.png index e8e616911..439c1d88d 100644 Binary files a/docs/source/img/main_window_no_project.png and b/docs/source/img/main_window_no_project.png differ diff --git a/docs/source/img/main_window_spineopt_load_template_ready.png b/docs/source/img/main_window_spineopt_load_template_ready.png deleted file mode 100644 index 96eb2ac5c..000000000 Binary files a/docs/source/img/main_window_spineopt_load_template_ready.png and /dev/null differ diff --git a/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png b/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png deleted file mode 100644 index 46ae6f446..000000000 Binary files a/docs/source/img/partial_dag_input_datastore-tool-output_data_store.png and /dev/null differ diff --git a/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png b/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png deleted file mode 100644 index bf846c289..000000000 Binary files a/docs/source/img/pass_input_txt_dc_and_say_hello_world_tool.png and /dev/null differ diff --git a/docs/source/img/pass_input_txt_dc_properties.png b/docs/source/img/pass_input_txt_dc_properties.png deleted file mode 100644 index c535fa60f..000000000 Binary files a/docs/source/img/pass_input_txt_dc_properties.png and /dev/null differ diff --git a/docs/source/img/pass_input_txt_dc_properties_with_file.png b/docs/source/img/pass_input_txt_dc_properties_with_file.png deleted file mode 100644 index 2d51e67be..000000000 Binary files a/docs/source/img/pass_input_txt_dc_properties_with_file.png and /dev/null differ diff --git a/docs/source/img/project_item_icons/database.svg b/docs/source/img/project_item_icons/database.svg deleted file mode 100644 index a6e4982bc..000000000 --- a/docs/source/img/project_item_icons/database.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/source/img/project_item_icons/hammer.svg b/docs/source/img/project_item_icons/hammer.svg deleted file mode 100644 index 78df6b490..000000000 --- a/docs/source/img/project_item_icons/hammer.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/source/img/project_item_icons/play-circle.svg b/docs/source/img/project_item_icons/play-circle.svg deleted file mode 100644 index ea1039704..000000000 --- a/docs/source/img/project_item_icons/play-circle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/docs/source/img/pypsa_dc.png b/docs/source/img/pypsa_dc.png deleted file mode 100644 index e32941440..000000000 Binary files a/docs/source/img/pypsa_dc.png and /dev/null differ diff --git a/docs/source/img/release06_main_window_empty.png b/docs/source/img/release06_main_window_empty.png deleted file mode 100644 index d866bf8c0..000000000 Binary files a/docs/source/img/release06_main_window_empty.png and /dev/null differ diff --git a/docs/source/img/settings_db_editor.png b/docs/source/img/settings_db_editor.png index 093b7788b..6620df7d1 100644 Binary files a/docs/source/img/settings_db_editor.png and b/docs/source/img/settings_db_editor.png differ diff --git a/docs/source/img/settings_project.png b/docs/source/img/settings_project.png deleted file mode 100644 index 5ede2d07f..000000000 Binary files a/docs/source/img/settings_project.png and /dev/null differ diff --git a/docs/source/img/settings_tools_default.png b/docs/source/img/settings_tools_default.png index c87bf15fb..a48971213 100644 Binary files a/docs/source/img/settings_tools_default.png and b/docs/source/img/settings_tools_default.png differ diff --git a/docs/source/img/settings_tools_filled_for_spineopt_github.png b/docs/source/img/settings_tools_filled_for_spineopt_github.png deleted file mode 100644 index 068777a7e..000000000 Binary files a/docs/source/img/settings_tools_filled_for_spineopt_github.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_nodes.png b/docs/source/img/simple_system_add_nodes.png deleted file mode 100644 index 67857eacf..000000000 Binary files a/docs/source/img/simple_system_add_nodes.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_output.png b/docs/source/img/simple_system_add_output.png deleted file mode 100644 index 90bc94bec..000000000 Binary files a/docs/source/img/simple_system_add_output.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_report__output_relationships.png b/docs/source/img/simple_system_add_report__output_relationships.png deleted file mode 100644 index ba81502d3..000000000 Binary files a/docs/source/img/simple_system_add_report__output_relationships.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_unit__from_node_relationships.png b/docs/source/img/simple_system_add_unit__from_node_relationships.png deleted file mode 100644 index 1cb905028..000000000 Binary files a/docs/source/img/simple_system_add_unit__from_node_relationships.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_unit__to_node_relationships.png b/docs/source/img/simple_system_add_unit__to_node_relationships.png deleted file mode 100644 index 6ce50d349..000000000 Binary files a/docs/source/img/simple_system_add_unit__to_node_relationships.png and /dev/null differ diff --git a/docs/source/img/simple_system_add_units.png b/docs/source/img/simple_system_add_units.png deleted file mode 100644 index 884d788fc..000000000 Binary files a/docs/source/img/simple_system_add_units.png and /dev/null differ diff --git a/docs/source/img/simple_system_cmdline_args.png b/docs/source/img/simple_system_cmdline_args.png deleted file mode 100644 index cd86391f7..000000000 Binary files a/docs/source/img/simple_system_cmdline_args.png and /dev/null differ diff --git a/docs/source/img/simple_system_electricity_demand.png b/docs/source/img/simple_system_electricity_demand.png deleted file mode 100644 index 053809be8..000000000 Binary files a/docs/source/img/simple_system_electricity_demand.png and /dev/null differ diff --git a/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png b/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png deleted file mode 100644 index e4ff0cbf0..000000000 Binary files a/docs/source/img/simple_system_fix_ratio_out_in_unit_flow.png and /dev/null differ diff --git a/docs/source/img/simple_system_fuel_balance_type.png b/docs/source/img/simple_system_fuel_balance_type.png deleted file mode 100644 index 2c74f1b40..000000000 Binary files a/docs/source/img/simple_system_fuel_balance_type.png and /dev/null differ diff --git a/docs/source/img/simple_system_item_connections.png b/docs/source/img/simple_system_item_connections.png deleted file mode 100644 index 709558114..000000000 Binary files a/docs/source/img/simple_system_item_connections.png and /dev/null differ diff --git a/docs/source/img/simple_system_power_plant_a_capacity.png b/docs/source/img/simple_system_power_plant_a_capacity.png deleted file mode 100644 index 577645a1f..000000000 Binary files a/docs/source/img/simple_system_power_plant_a_capacity.png and /dev/null differ diff --git a/docs/source/img/simple_system_power_plant_a_vom_cost.png b/docs/source/img/simple_system_power_plant_a_vom_cost.png deleted file mode 100644 index cce73b1f5..000000000 Binary files a/docs/source/img/simple_system_power_plant_a_vom_cost.png and /dev/null differ diff --git a/docs/source/img/simple_system_power_plant_b_capacity.png b/docs/source/img/simple_system_power_plant_b_capacity.png deleted file mode 100644 index dfc1f4b1f..000000000 Binary files a/docs/source/img/simple_system_power_plant_b_capacity.png and /dev/null differ diff --git a/docs/source/img/simple_system_power_plant_b_vom_cost.png b/docs/source/img/simple_system_power_plant_b_vom_cost.png deleted file mode 100644 index 3b807e5f4..000000000 Binary files a/docs/source/img/simple_system_power_plant_b_vom_cost.png and /dev/null differ diff --git a/docs/source/img/simple_system_results_pivot_table.png b/docs/source/img/simple_system_results_pivot_table.png deleted file mode 100644 index ebc979499..000000000 Binary files a/docs/source/img/simple_system_results_pivot_table.png and /dev/null differ diff --git a/docs/source/img/simple_system_schematic.png b/docs/source/img/simple_system_schematic.png deleted file mode 100644 index 0839f8bb8..000000000 Binary files a/docs/source/img/simple_system_schematic.png and /dev/null differ diff --git a/docs/source/img/spine_datapackage_editor_pypsa.png b/docs/source/img/spine_datapackage_editor_pypsa.png deleted file mode 100644 index 09c4ed631..000000000 Binary files a/docs/source/img/spine_datapackage_editor_pypsa.png and /dev/null differ diff --git a/docs/source/img/spine_opt_plugin_tool_bar.png b/docs/source/img/spine_opt_plugin_tool_bar.png deleted file mode 100644 index 233734632..000000000 Binary files a/docs/source/img/spine_opt_plugin_tool_bar.png and /dev/null differ diff --git a/docs/source/img/tabular_view_edit_data.png b/docs/source/img/tabular_view_edit_data.png deleted file mode 100644 index cd232ad8a..000000000 Binary files a/docs/source/img/tabular_view_edit_data.png and /dev/null differ diff --git a/docs/source/img/tabular_view_edit_index.png b/docs/source/img/tabular_view_edit_index.png deleted file mode 100644 index a96875b06..000000000 Binary files a/docs/source/img/tabular_view_edit_index.png and /dev/null differ diff --git a/docs/source/img/tree_view_table_parameter_tools.png b/docs/source/img/tree_view_table_parameter_tools.png deleted file mode 100644 index fac75ec20..000000000 Binary files a/docs/source/img/tree_view_table_parameter_tools.png and /dev/null differ diff --git a/docs/source/img/tree_view_table_tab.png b/docs/source/img/tree_view_table_tab.png deleted file mode 100644 index a4d929f93..000000000 Binary files a/docs/source/img/tree_view_table_tab.png and /dev/null differ diff --git a/docs/source/img/two_hydro.png b/docs/source/img/two_hydro.png deleted file mode 100644 index ac193a0e6..000000000 Binary files a/docs/source/img/two_hydro.png and /dev/null differ diff --git a/docs/source/img/two_hydro_commodities.png b/docs/source/img/two_hydro_commodities.png deleted file mode 100644 index a9ee3e0a6..000000000 Binary files a/docs/source/img/two_hydro_commodities.png and /dev/null differ diff --git a/docs/source/img/two_hydro_connection_from_node.png b/docs/source/img/two_hydro_connection_from_node.png deleted file mode 100644 index 962d27b8e..000000000 Binary files a/docs/source/img/two_hydro_connection_from_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_connection_node_node.png b/docs/source/img/two_hydro_connection_node_node.png deleted file mode 100644 index 5d22c430c..000000000 Binary files a/docs/source/img/two_hydro_connection_node_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_connection_node_node_parameters.png b/docs/source/img/two_hydro_connection_node_node_parameters.png deleted file mode 100644 index e4881c7ed..000000000 Binary files a/docs/source/img/two_hydro_connection_node_node_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_connections.png b/docs/source/img/two_hydro_connections.png deleted file mode 100644 index ad26202dc..000000000 Binary files a/docs/source/img/two_hydro_connections.png and /dev/null differ diff --git a/docs/source/img/two_hydro_fix_node_state.png b/docs/source/img/two_hydro_fix_node_state.png deleted file mode 100644 index b5d25e9e9..000000000 Binary files a/docs/source/img/two_hydro_fix_node_state.png and /dev/null differ diff --git a/docs/source/img/two_hydro_max_stored_water_unit_node_node.png b/docs/source/img/two_hydro_max_stored_water_unit_node_node.png deleted file mode 100644 index 2e1961843..000000000 Binary files a/docs/source/img/two_hydro_max_stored_water_unit_node_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_max_stored_water_unit_values.png b/docs/source/img/two_hydro_max_stored_water_unit_values.png deleted file mode 100644 index 1a43f8021..000000000 Binary files a/docs/source/img/two_hydro_max_stored_water_unit_values.png and /dev/null differ diff --git a/docs/source/img/two_hydro_min_spill_unit_node_node.png b/docs/source/img/two_hydro_min_spill_unit_node_node.png deleted file mode 100644 index 02d37c11d..000000000 Binary files a/docs/source/img/two_hydro_min_spill_unit_node_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_model_parameters.png b/docs/source/img/two_hydro_model_parameters.png deleted file mode 100644 index 16e0510ec..000000000 Binary files a/docs/source/img/two_hydro_model_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_node_commodities.png b/docs/source/img/two_hydro_node_commodities.png deleted file mode 100644 index d283a62f4..000000000 Binary files a/docs/source/img/two_hydro_node_commodities.png and /dev/null differ diff --git a/docs/source/img/two_hydro_node_parameters.png b/docs/source/img/two_hydro_node_parameters.png deleted file mode 100644 index 901225ee6..000000000 Binary files a/docs/source/img/two_hydro_node_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_nodes.png b/docs/source/img/two_hydro_nodes.png deleted file mode 100644 index 7793302a6..000000000 Binary files a/docs/source/img/two_hydro_nodes.png and /dev/null differ diff --git a/docs/source/img/two_hydro_report.png b/docs/source/img/two_hydro_report.png deleted file mode 100644 index 401e2ad5f..000000000 Binary files a/docs/source/img/two_hydro_report.png and /dev/null differ diff --git a/docs/source/img/two_hydro_results_discharge.png b/docs/source/img/two_hydro_results_discharge.png deleted file mode 100644 index 9c73b51f1..000000000 Binary files a/docs/source/img/two_hydro_results_discharge.png and /dev/null differ diff --git a/docs/source/img/two_hydro_results_electricity.png b/docs/source/img/two_hydro_results_electricity.png deleted file mode 100644 index 7a64abea8..000000000 Binary files a/docs/source/img/two_hydro_results_electricity.png and /dev/null differ diff --git a/docs/source/img/two_hydro_temporal_block.png b/docs/source/img/two_hydro_temporal_block.png deleted file mode 100644 index a48ee5d68..000000000 Binary files a/docs/source/img/two_hydro_temporal_block.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_from_node.png b/docs/source/img/two_hydro_unit_from_node.png deleted file mode 100644 index 8279b3836..000000000 Binary files a/docs/source/img/two_hydro_unit_from_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_from_node_parameters.png b/docs/source/img/two_hydro_unit_from_node_parameters.png deleted file mode 100644 index 819431e01..000000000 Binary files a/docs/source/img/two_hydro_unit_from_node_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_node_node.png b/docs/source/img/two_hydro_unit_node_node.png deleted file mode 100644 index 54745c4a1..000000000 Binary files a/docs/source/img/two_hydro_unit_node_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_node_node_parameters.png b/docs/source/img/two_hydro_unit_node_node_parameters.png deleted file mode 100644 index 59651de2d..000000000 Binary files a/docs/source/img/two_hydro_unit_node_node_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_to_node.png b/docs/source/img/two_hydro_unit_to_node.png deleted file mode 100644 index 2b3960043..000000000 Binary files a/docs/source/img/two_hydro_unit_to_node.png and /dev/null differ diff --git a/docs/source/img/two_hydro_unit_to_node_parameters.png b/docs/source/img/two_hydro_unit_to_node_parameters.png deleted file mode 100644 index 3d9e8da0c..000000000 Binary files a/docs/source/img/two_hydro_unit_to_node_parameters.png and /dev/null differ diff --git a/docs/source/img/two_hydro_units.png b/docs/source/img/two_hydro_units.png deleted file mode 100644 index cae2504fc..000000000 Binary files a/docs/source/img/two_hydro_units.png and /dev/null differ diff --git a/docs/source/img/two_hydro_vom_cost.png b/docs/source/img/two_hydro_vom_cost.png deleted file mode 100644 index 2898d1810..000000000 Binary files a/docs/source/img/two_hydro_vom_cost.png and /dev/null differ diff --git a/docs/source/img/using_input_output_files_in_tool_scripts.png b/docs/source/img/using_input_output_files_in_tool_scripts.png new file mode 100644 index 000000000..57b1ae8e5 Binary files /dev/null and b/docs/source/img/using_input_output_files_in_tool_scripts.png differ diff --git a/docs/source/main_window.rst b/docs/source/main_window.rst index 911e8e4ae..1da177f7d 100644 --- a/docs/source/main_window.rst +++ b/docs/source/main_window.rst @@ -23,13 +23,12 @@ The first time you start the application you will see the main window like this. .. image:: img/main_window_no_project.png :align: center -The application main window contains four dock widgets (**Project**, **Properties**, **Event Log**, **Console**), a -**Toolbar**, a **Design View**, and a menu bar with **File**, **Edit**, **View**, **Plugins**, **Consoles**, **Server** -and **Help** menus. The **Project** dock widget contains a list of project items in the project. The **Properties** -dock widget shows the properties of the selected project item. **Event Log** shows messages based on user actions and -item executions. It shows messages from processes that are spawned by the application, i.e. it shows the stdout and -stderr streams of GAMS and executable programs. Also things like whether an item's execution was successful and -when the project or an item specification is saved are shown. +The application main window contains four dock widgets (**Design View**, **Properties**, **Event Log**, **Console**), a +**Toolbar**, and a menu bar with **File**, **Edit**, **View**, **Plugins**, **Consoles**, **Server** +and **Help** menus. The **Properties** dock widget shows the properties of the selected project item. **Event Log** +shows messages based on user actions and item executions. It shows messages from processes that are spawned by the +application, i.e. it shows the stdout and stderr streams of executable programs. In addition, it displays messages +related to item's execution. **Console** provides Julia and Python consoles that can be interacted with. What kind of console is shown depends on the Tool type of the specific Tool. Only an item that is currently executing or has already @@ -49,8 +48,8 @@ contains a link to this documentation as well as various tidbits about Spine Too The **Items** section of the **Toolbar** contains the available :ref:`project item ` types. -The **Execute** section contains icons that control the execution of the items in the **Design view** where you build your project. -The |play-all| button executes all Directed Acyclic Graphs (DAG) in the project in a row. The |play-selected| button +The **Execute** section contains icons that control the execution of the items in the **Design view**. The |play-all| +button executes all workflows in the project in parallel. The |play-selected| button executes the selected project items only. The |stop| button terminates the execution (if running). You can add a new project item to your project by pointing your mouse cursor on any of the draggable items @@ -59,19 +58,9 @@ After this you will be presented a dialog, which asks you to fill in basic infor item (name, description, etc.). The main window is very customizable so you can e.g. close the dock widgets that you do not need, rearrange the order -of the dock widgets by dragging them around and/or resize the views to fit your needs and display size or resolution. You can find more ways to -customize the visual elements of Spine Toolbox in the :ref:`settings `. +of the dock widgets by dragging them around and/or resize the views to fit your needs and display size or +resolution. You can find more ways to customize the visual elements of Spine Toolbox in the :ref:`settings `. .. note:: If you want to restore all dock widgets to their default place use the menu item **View -> Dock Widgets -> Restore Dock Widgets**. This will show all hidden dock widgets and restore them to the main window. - -Below is an example on how you can customize the main window. In the picture, a user has created a project `New -Project` and created one project item from each of the eight categories. A Data Connection called `Data files`, -a Data Store called `Database`, a Data Transformer called `Data Transformer`, an Exporter called `Exporter`, -an Importer called `Importer`, a Merger called `Merger`, a Tool called `Julia model` and a View called `View`. The -project items are also listed in the **Project** dock widget. Some of the dock widgets have also been moved from -their original places. - -.. image:: img/main_window_new_project_with_project_items.png - :align: center diff --git a/docs/source/parameter_value_editor.rst b/docs/source/parameter_value_editor.rst index 3253527ed..6ab07b97e 100644 --- a/docs/source/parameter_value_editor.rst +++ b/docs/source/parameter_value_editor.rst @@ -4,7 +4,7 @@ Parameter Value Editor ********************** -Parameter value editor is used to edit object and relationship parameter values +Parameter value editor is used to edit entity parameter values such as time series, time patterns or durations. It can also convert between different value types, e.g. from a time series to a time pattern. diff --git a/docs/source/project_items.rst b/docs/source/project_items.rst index 35acfc278..051c54c0b 100644 --- a/docs/source/project_items.rst +++ b/docs/source/project_items.rst @@ -49,7 +49,6 @@ Those interested in looking under the hood can check the :ref:`Project item deve Project Item Properties ----------------------- - Each project item has its own set of `properties`. You can view and edit them by selecting a project item in the **Design View**. The properties are displayed in the **Properties** dock widget on the main window. Project item properties are saved into the project save file (``project.json``), which can be diff --git a/docs/source/setting_up.rst b/docs/source/setting_up.rst index 56f731a51..9ffd5d244 100644 --- a/docs/source/setting_up.rst +++ b/docs/source/setting_up.rst @@ -23,7 +23,7 @@ them up. To get started with **SpineOpt.jl**, see :ref:`How to Set up SpineOpt.j Python and Julia Tools can be executed either in an embedded *Basic Console* or in a *Jupyter Console*. GAMS Tools are executed in a sub-process. Executable Tools (external programs) are executed in a shell or by running the -executable file straight. You can also make a Tool that executes a shell command by creating an *Executable* Tool +executable file directly. You can also make a Tool that executes a shell command by creating an *Executable* Tool Spec in **Tool Specification Editor**, entering the shell command to the *Command* line edit and then selecting the Shell for this Tool. @@ -46,17 +46,16 @@ Spec. Editor**. Then drag the Python Tool Spec into the **Design View**, and pre Julia ***** -To execute Julia Tools in the Basic Console, first install Julia (v1.6 or later) +To execute Julia Tools in the Basic Console, first install Julia (v1.6 or later recommended) `from here `_ and add `/bin` to your PATH environment variable (if not done automatically by the installer). Then go to the *Tools* page in **File -> Settings** and make sure that the Basic Console radio button is selected in the Julia group. If Julia is in your PATH, the Julia executable line edit should show the path as (grey) placeholder text. If you want to use another Julia on your system, you can change the path in the line edit. You can also set a Julia Project below the Julia executable line edit. -.. note:: The Julia settings are *global* application settings. All Julia Tools are executed with the settings - selected on the *Tools* page in **File -> Settings**. In upcoming versions, the Julia settings will be consistent - with the Python settings, in a way that you can select a specific Julia executable and Julia project for each - Julia Tool Spec separately. +.. note:: The Julia settings on the *Tools* page in **File -> Settings** are the *default* settings for new Julia + Tool Specs. You can select a different Julia executable & project for each Julia Tool Spec separately using the + **Tool Specification Editor**. Jupyter Consoles ---------------- @@ -79,11 +78,11 @@ specific instructions for creating kernel specs for Conda environments below. .. image:: img/python_jupyter_console_selected.png :align: center -Once the **ipykernel** package is installed, the wizard runs the **ipykernel** install command, which creates the -kernel specs directory on your system. You can quickly open the kernel spec directory from the -**Select Python Kernel...** combo box's context-menu (mouse right-click menu). Once the process finishes, -click Close, and the newly created kernel spec (*python39* in this case) should be selected automatically. -Click *Ok* to close the **Settings** widget and to save your selections. +Once the **ipykernel** package is installed, the wizard runs the **ipykernel install** command, which creates the +kernel specs directory on your system. Once the process finishes, click *Close*, and the newly created kernel spec +(*python39* in this case) should be selected automatically. For convenience, there is a context-menu (mouse +right-click menu) in the **Select Python Kernel...** combo box that opens the the kernel spec directory in your +file browser. Click *Ok* to close the **Settings** widget and to save your selections. .. image:: img/python_kernel_specification_creator.png :align: center @@ -101,7 +100,10 @@ And to install the kernel specs run:: python -m ipykernel install --user --name python39 --display-name python39_spinetoolbox -Make sure to use the ``--user`` argument to make sure that the kernel specs are discoverable by Spine Toolbox. +Make sure to use the ``--user`` argument to in order to make the kernel specs discoverable by Spine Toolbox. + +.. important:: If you want to have access to `spinedb_api`, you need to install it manually for the Python you + select here. .. note:: Clicking **Make Python Kernel** button when the kernel specs have already been installed, does NOT open the @@ -112,9 +114,6 @@ Make sure to use the ``--user`` argument to make sure that the kernel specs are This means, that if you still have some old Python 2.7 scripts lying around, you can incorporate those into a Spine Toolbox project workflow and execute them without modifications. -.. important:: If you want to have access to `spinedb_api`, you need to install it manually for the Python you - select here. - Julia ***** To use the Jupyter Console with Julia Tools, go to the *Tools* page in **File -> Settings** and select the diff --git a/docs/source/settings.rst b/docs/source/settings.rst index 2d0e34834..c4731d9c6 100644 --- a/docs/source/settings.rst +++ b/docs/source/settings.rst @@ -3,6 +3,9 @@ .. |open-folder| image:: ../../spinetoolbox/ui/resources/menu_icons/folder-open-solid.svg :width: 16 +.. |share| image:: ../../spinetoolbox/ui/resources/share.svg + :width: 16 + .. _Settings: ******** @@ -110,19 +113,23 @@ Choose the settings on how Julia Tools are executed. - **Select Julia kernel... drop-down menu** Select the kernel you want to launch in Jupyter Console. +.. note:: The Julia settings above are **the default settings for new Julia Tool Specs**. You can select a + specific Julia executable & project or Julia kernel for each Tool Spec separately using the + **Tool Specification Editor**. + - **Make Julia Kernel** Clicking this button makes a new kernel based on the selected *Julia executable*, and *Julia project*. The progress of the operation is shown in another dialog. Installing a Julia kernel requires the **IJulia** package which will be installed to the selected *Julia project*. After **IJulia** has been installed, the kernel is installed. This process can take a couple of minutes to finish. +- |share| This button sets **all Julia Tools** execution settings in the current project to defaults. This + operation is irreversible because the project will be saved afterwards. + - **Install Julia** Installs the latest Julia on your system using the **jill** package. - **Add/Update SpineOpt** Installs the latest compatible **SpineOpt** to the selected Julia project. If the selected *Julia project* already has SpineOpt, it is upgraded if there is a new version available. -.. note:: These Julia settings are *global* application settings. All Julia Tools are executed with the settings - selected here. - Settings in the **Python** group: Choose the settings on how Python Tools are executed. @@ -137,13 +144,17 @@ Choose the settings on how Python Tools are executed. - **Select Python kernel... drop-down menu** Select the kernel you want to launch in Jupyter Console. +.. note:: The Python settings above are **the default settings for new Python Tool Specs**. You can select a + specific Python executable or Python kernel for each Python Tool Spec separately using the + **Tool Specification Editor**. + - **Make Python Kernel** clicking this button makes a new kernel based on the selected *Python executable*. The progress of the operation is shown in another dialog. Installing a Python kernel (actually IPython kernel) requires the **ipykernel** package which will be installed to the selected *Python executables*. After **ipykernel** has been installed, the kernel is installed. This process can take a couple of minutes to finish. -.. note:: These Python settings are just the default settings *for new Python Tool Specs*. You can select a - specific Python kernel for each Python Tool Spec separately using the **Tool Specification Editor**. +- |share| This button sets **all Python Tools** execution settings in the current project to defaults. This + operation is irreversible because the project will be saved afterwards. Settings in the **Conda** group: @@ -152,6 +163,8 @@ Settings in the **Conda** group: See :ref:`Setting up Consoles and External Tools` for more information and examples. +.. _DB editor settings: + Db editor Settings ------------------ @@ -161,6 +174,8 @@ Db editor Settings This tab contains settings for the Spine Database editor. The same settings can be accessed directly from the Database editor itself. +Settings in the **General** group: + - **Commit session before closing** This checkbox controls what happens when you close a database editor which has uncommitted changes. When this is unchecked, all changes are discarded without notice. When this is partially checked (default), a message box warning you about uncommitted @@ -170,28 +185,38 @@ from the Database editor itself. - **Show undo notifications** Checking this will show undo notification boxes in the editor every time something undoable happens. Unchecking hides the notifications. +Settings in the **Entity tree** group: + - **Sticky selection in entity trees** Controls how selecting items in Spine database editor's - Object and Relationships trees using the left mouse button works. + **Entity Tree** using the left mouse button works. If checked, multiple selection is enabled and pressing **Ctrl** enables single selection. If unchecked, single selection is enabled and pressing **Ctrl** enables multiple selection. -- **Move relationships along with objects in Entity graph** This controls how relationship nodes - behave on the Graph view when object nodes are moved around. - If checked, connected relationship nodes move along with the object node. - If unchecked, connected relationship nodes remain where they are when objects nodes are moved. +Settings in the **Entity graph** group: + +- **Auto-expand entities** This checkbox controls which N-D entities are automatically shown on the Graph view. + If checked, all N-D entities that are related to the selection are included automatically. + If unchecked, only N-D entities that have all the elements visible in the graph are shown automatically. -- **Smooth Entity graph zoom** Checking this enables smooth zoom on the Graph view. +- **Merge databases** If checked, Graph view will combine all databases + that are open on the same table into a single graph if they contains common entity nodes. + If unchecked, a separate graph will be drawn for each database. -- **Smooth Entity graph rotation** Checking this enables smooth rotation on the Graph view. +- **Snap entities to grid** Makes it so that the placement of the entities can’t be arbitrary anymore + but instead they can only lay on a grid. -- **Auto-expand objects by default in Entity graph** This checkbox controls which relationship - nodes to show on the Graph view. - If checked, all relationships that contain a visible object node are included. - If unchecked, relationship nodes are included only if all their objects are show on the Graph view. +- **Smooth zoom** Checking this enables smooth zoom on the Graph view. -- **Merge databases by default in Entity graph** If checked, Graph view will combine all databases - that are open on the same table into a single graph if they contains common object nodes. - If unchecked, a separate graph will be drawn for each database. +- **Smooth rotation** Checking this enables smooth rotation on the Graph view. + +- **Max. entity dimension count** Defines a cutoff for the number of dimensions an entity can have and still be drawn. + +- **Number of build iterations** Defines the maximum numbers of iterations the layout generation algorithm can make. + +- **Minimum distance between nodes (%)** Used for setting the ideal distance between entities in the graph. + +- **Decay rate of attraction with distance** The higher this number, the lesser the attraction between + distant vertices when drawing the graph. Spec. editor Settings --------------------- diff --git a/docs/source/spine_db_editor/adding_data.rst b/docs/source/spine_db_editor/adding_data.rst index d8f52d226..7478c28d6 100644 --- a/docs/source/spine_db_editor/adding_data.rst +++ b/docs/source/spine_db_editor/adding_data.rst @@ -1,182 +1,180 @@ +.. |add| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_plus.svg + :width: 16 +.. |remove| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_minus.svg + :width: 16 + Adding data ----------- -This section describes the available tools to add new data. +This section describes the available tools to add new data. Note that after adding +data to a Spine Database, it still needs to be committed in order for the changes +to take effect beyond just the current session in the Spine Database Editor. More +information about this in the chapter :ref:`committing_and_history`. .. contents:: :local: - -Adding object classes +Adding entity classes ===================== -From *Object tree* -~~~~~~~~~~~~~~~~~~ +From **Entity Tree** +~~~~~~~~~~~~~~~~~~~~ -Right-click on the root item in *Object tree* to display the context menu, and select **Add object classes**. +Right-click on the root item in **Entity Tree** to display the context menu, and select **Add entity classes**. -The *Add object classes* dialog will pop up: +The *Add entity classes* dialog will pop up: -.. image:: img/add_object_classes_dialog.png +.. image:: img/add_entity_classes_dialog.png :align: center -Enter the names of the classes you want to add under the *object class name* column. -Optionally, you can enter a description for each class under the *description* column. -To select icons for your classes, double click on the corresponding cell under the *display icon* column. -Finally, select the databases where you want to add the classes under *databases*. -When you're ready, press **Ok**. +Select the number of dimensions using the spinbox at the top. The amount of dimensions determines the number +of dimension names that need to be selected. With 0-dimensional classes, like in the image above, only the name +of the created entity class is required. The class's name is to be entered below the header **entity class name**. +In other cases like in the image below, in addition to the created classes name, also the classes making up the new +class need to be selected: -Adding objects -============== - -From *Object tree* or *Entity graph* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Right-click on an object class item in *Object tree*, or on an empty space in the *Entity graph*, -and select **Add objects** from the context menu. +.. image:: img/add_entity_classes_dialog_2D.png + :align: center -The *Add objects* dialog will pop up: +They need to be filled under the headers **dimension name (1)** through **dimension name (N)** where N is the +selected dimension. Note that because of this, there needs to be at least N entity classes already defined in the +database when creating an N dimensional entity class. To display a list of available classes, start typing or double +click on the cell below the header. Optionally, you can enter a description for each class under the **description** +header. Double clicking the cell under the header **display icon** will open up the icon editor where the visual +representation of the class can be modified: -.. image:: img/add_objects_dialog.png +.. image:: img/entity_icon_editor.png :align: center -Enter the names of the object classes under *object class name*, and the names of the objects under *object name*. -To display a list of available classes, start typing or double click on any cell under the *object class name* column. -Optionally, you can enter a description for each object under the *description* column. -Finally, select the databases where you want to add the objects under *databases*. -When you're ready, press **Ok**. +The boolean value of **active by default** will determine whether the entities created under the created class +will have the value under entity alternative set as true or false by default. Finally, select the databases where +you want to add the entity classes under **databases**. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ -To add an object to a specific class, bring the class to *Pivot table* using any input type -(see :ref:`using_pivot_table_and_frozen_table`). -Then, enter the object name in the last cell of the header corresponding to that class. +Multiple additions can be at once. When some information is inserted into the preceding row, a new empty row will +appear in the dialog where a new class with the same dimensions can be defined. Delete entire rows from the dialog +with **Remove selected rows**. When you're ready, press **Ok** to make the additions. -Duplicating objects -~~~~~~~~~~~~~~~~~~~ -To duplicate an existing object with all its relationships and parameter values, -right-click over the corresponding object item in *Object tree* to display the context menu, -and select **Duplicate object**. Enter a name for the duplicate and press **Ok**. +.. tip:: All the *Add...* dialogs support pasting tabular (spreadsheet) data from the clipboard. + Just select any cell in the table and press **Ctrl+V**. + If needed, the table will grow to accommodate the exceeding data. + To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**. +Adding entities +=============== -Adding object groups -==================== +From **Entity Tree** or **Graph View** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Right-click on an object class item in *Object tree*, -and select **Add object group** from the context menu. +Right-click on the root item in **Entity Tree** and select **Add entities**, or click on an empty space +in the **Graph View** and select **Add entities...** from the context menu. -The *Add object group* dialog will pop up: +This will open up the **Add entities** dialog: -.. image:: img/add_object_group_dialog.png +.. image:: img/add_entities_dialog.png :align: center -Enter the name of the group, and select the database where you want the group to be created. -Select the member objects under *Non members*, and press the button in the middle that has a plus sign. -Multiple selection works. +Select the class where you want to add the entities from **Entity class**. It will list all of the entity classes. +To narrow down the list, instead of opening the dialog from the root item, open it from a specific entity class item +in the **Entity Tree**. This way the class will be preselected from the list and the list will overall only contain +other classes that are relevant to the selected class. -When you're happy with your selections, press **Ok** to add the group to the database. +Enter the names of the entities under **entity name**. Finally, select the databases where you want to add the +entities under **databases**. When you're ready, press **Ok**. Rows can once again be deleted with the +**Remove selected rows** -button. +With N-D entity classes, the elements need to be specified. After defining the elements the entity's name can be +modified: -Adding relationship classes -=========================== +.. image:: img/add_entities_dialog_2D.png + :align: center -From *Object tree* or *Relationship tree* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Right-click on an object class item in *Object tree*, or on the root item in *Relationship tree*, -and select **Add relationship classes** from the context menu. +New entities for an existing N-D entity class can also be created easily from the **Graph view**. +Make sure all the entities you want as members in the new entity are in the graph. +To start the new N-D entity, either double click on one of the entity items in the graph, +or right click on it to display the context menu, and choose the class from **Connect entities**. +After selecting the class the mouse cursor will adopt a cross-hairs shape. + +When hovering over a entity item, the cursor will aid by indicating an entity that can't be a +member by turning into a red restriction -sign. When nearly all of the selections made and only the +last member needs to be selected, the cursor will turn into a green checkmark when hovering over an +appropriate entity. Click on each of the remaining member entities one by one to add them to the new +entity. Once you've added enough members for the entity class, a dialog will pop up. In the dialog, +all of the possible permutations of the selected members are presented. Check the boxes next to the +entities you want to add, and press **OK**. + +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ -The *Add relationship classes* dialog will pop up: +To add an entity to a specific 0-D entity class, bring the class to **Pivot View** using either **Value** or **Index** +(see :ref:`using_pivot_table_and_frozen_table`). There under the class name just type a new name and the new entity +will be added under the class. Note that is only possible to add 0-D entities this way even if you have selected +an N-D class from the **Entity Tree**. -.. image:: img/add_relationship_classes_dialog.png - :align: center +To enter a new entity to an N-D class, select the **Element** -view from the hamburger menu. This view contains +all of the possible combinations of elements in the selected class. The entities can be added by checking the +boxes and removed by unchecking them. -Select the number of dimensions using the spinbox at the top; -then, enter the names of the object classes for each dimension under each *object class name* column, -and the names of the relationship classes under *relationship class name*. -To display a list of available object classes, -start typing or double click on any cell under the *object class name* columns. -Optionally, you can enter a description for each relationship class under the *description* column. -Finally, select the databases where you want to add the relationship classes under *databases*. -When you're ready, press **Ok**. +Duplicating entities +~~~~~~~~~~~~~~~~~~~~ +To duplicate an existing entity with all its parameter values and other associated data, right-click over the +corresponding entity item in **Entity Tree** to display the context menu, and select **Duplicate entity**. The +new entity will have the same name with an added (1) to indicate that it is a copy of the original entity. It +can be renamed to be something else afterwards. -Adding relationships -==================== -From *Object tree* or *Relationship tree* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Adding entity groups +==================== -Right-click on a relationship class item either in *Object tree* or *Relationship tree*, -and select **Add relationships** from the context menu. +Right-click on an entity class item in **Entity Tree**, +and select **Add entity group** from the context menu. -The *Add relationships* dialog will pop up: +The **Add entity group** dialog will pop up: -.. image:: img/add_relationships_dialog.png +.. image:: img/add_entity_group_dialog.png :align: center -Select the relationship class from the combo box at the top; -then, enter the names of the objects for each member object class under the corresponding column, -and the name of the relationship under *relationship name*. -To display a list of available objects for a member class, -start typing or double click on any cell under that class's column. -Finally, select the databases where you want to add the relationships under *databases*. -When you're ready, press **Ok**. - -From *Pivot table* -~~~~~~~~~~~~~~~~~~ -To add a relationship for a specific class, -bring the class to *Pivot table* using the **Relationship** input type -(see :ref:`using_pivot_table_and_frozen_table`). -The *Pivot table* headers will be populated -with all possible combinations of objects across the member classes. -Locate the objects you want as members in the new relationship, -and check the corresponding box in the table body. +Enter the name of the group, and select the database where you want the group to be created. +Select the members under *Non members*, and press (|add|>>) to add the members and (|remove| <<) to remove them. +Multiple selection is supported with **Ctrl** and **Shift**. Finally press **OK** to create the group. -From *Entity graph* -~~~~~~~~~~~~~~~~~~~ -Make sure all the objects you want as members in the new relationship are in the graph. -To start the relationship, either double click on one of the object items, -or right click on it to display the context menu, and choose **Add relationships**. -A menu will pop up showing the available relationship classes. -Select the class you want; the mouse cursor will adopt a cross-hairs shape. -Click on each of the remaining member objects, one by one and in the right order, to add them to the relationship. -Once you've added enough objects for the relationship class, a dialog will pop up. -Check the boxes next to the relationships you want to add, and press **Ok**. - -.. tip:: All the *Add...* dialogs support pasting tabular (spreadsheet) data from the clipboard. - Just select any cell in the table and press **Ctrl+V**. - If needed, the table will grow to accommodate the exceeding data. - To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**. +When you're happy with your selections, press **OK** to add the group to the database. Adding parameter definitions ============================ -From *Stacked tables* -~~~~~~~~~~~~~~~~~~~~~ +From **Table View** +~~~~~~~~~~~~~~~~~~~~ + +To add new parameter definitions for an entity class, just fill the last empty row of *Parameter definition*. +Only two of the fields are required when creating a new parameter definition: *entity_class_name* and +*parameter_name*. Enter the name of the class under *entity_class_name*. To display a list of available +entity classes, start typing in the empty cell or double click it. For the name of the parameter choose +something that isn't already defined for the specified entity class. Optionally, you can also +specify a parameter value list, a default value and a description. -To add new parameter definitions for an object class, -just fill the last empty row of *Object parameter definition*. -Enter the name of the class under *object_class_name*, and the name of the parameter under *parameter_name*. -To display a list of available object classes, -start typing or double click under the *object_class_name* column. -Optionally, you can also specify a default value, a parameter value list, or any number of parameter tags -under the appropriate columns. -The parameter is added when the background of the cells under *object_class_name* -and *parameter_name* become gray. +In the column *value_list_name* a name for a parameter value list can be selected. Leaving this field empty +means that later on when creating parameter values with this definition, the values are arbitrary. Meaning that +the value could for example be a string or an integer. When the parameter value list is defined in the parameter +definition, only the values in the list will be allowed to be chosen. For the creation of parameter value lists, +see :ref:`parameter_value_list`. -To add new parameter definitions for a relationship class, -just fill the last empty row of *Relationship parameter definition*, -following the same guidelines as above. +In the *default_value* field, the default value can be set. The default value can be used in cases where the value +is not specified. The usage of *default_value* is really tool dependent, meaning that the Spine Database Editor +doesn't use the information of the default value anywhere, but it is instead left to the tool creators on how to +utilize the default value. A short description for the parameter can be written in the *description* column. +The parameter is added when the background of the cells under *entity_class_name* and *database* become gray. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ + +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ To add a new parameter definition for a class, -bring the corresponding class to *Pivot table* using the **Parameter value** input type +bring the corresponding class to **Pivot View** using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`). -The **parameter** header of *Pivot table* will be populated +The **parameter** header of **Pivot View** will be populated with existing parameter definitions for the class. Enter a name for the new parameter in the last cell of that header. @@ -184,74 +182,60 @@ Enter a name for the new parameter in the last cell of that header. Adding parameter values ======================= -From *Stacked tables* +From *Table View* ~~~~~~~~~~~~~~~~~~~~~ -To add new parameter values for an object, -just fill the last empty row of *Object parameter value*. -Enter the name of the class under *object_class_name*, the name of the object under *object_name*, +To add new parameter values for an entity, just fill the last empty row of the *Parameter value* -table. +Enter the name of the class under *entity_class_name*, the name of the entity under *entity_byname*, the name of the parameter under *parameter_name*, and the name of the alternative under *alternative_name*. -Optionally, you can also specify the parameter value right away under the *value* column. -To display a list of available object classes, objects, parameters, or alternatives, -just start typing or double click under the appropriate column. -The parameter value is added when the background of the cells under *object_class_name*, -*object_name*, and *parameter_name* become gray. - -To add new parameter values for a relationship class, -just fill the last empty row of *Relationship parameter value*, -following the same guidelines as above. +Optionally, you can also specify the parameter value right away under the *value* column. The database where +the value will be added to is displayed in the last column of the table. To display a list of available +entity classes, entities, parameters, or alternatives, just start typing or double click under the appropriate +column. The parameter value is added when the background of the cells under *entity_class_name* and *database* +become gray. -.. note:: To add parameter values for an object, the object has to exist beforehand. - However, when adding parameter values for a relationship, you can specify any valid combination - of objects under *object_name_list*, and a relationship will be created among those objects - if one doesn't yet exist. +.. note:: To add parameter values for a 0-D entity, the entity has to exist beforehand. + However, when adding parameter values for an N-D entity, you can specify any valid combination + of elements by double clicking the cell under *entity_byname*, which opens up the *Select elements* -dialog. + The specified N-D entity will be created if it doesn't yet exist. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ -To add parameter value for any object or relationship, -bring the corresponding class to *Pivot table* using the **Parameter value** input type +To add parameter value for any entity, +bring the corresponding class to **Pivot View** using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`). Then, enter the parameter value in the corresponding cell in the table body. -.. tip:: All *Stacked tables* and *Pivot table* support pasting tabular (e.g., spreadsheet) data from the clipboard. - Just select any cell in the table and press **Ctrl+V**. - If needed, *Stacked tables* will grow to accommodate the exceeding data. +.. tip:: All **Tables Views** and **Pivot Views** support pasting tabular (e.g., spreadsheet) data from the clipboard. + Just select any cell in the table and press **Ctrl+V**. + If needed, **Table Views** will grow to accommodate the exceeding data. To paste data on multiple cells, select all the cells you want to paste on and press **Ctrl+V**. -Adding tools, features, and methods -=================================== - -To add a new feature, go to *Tool/Feature tree* and select the last item under **feature** in the appropriate database, -start typing or press **F2** to display available parameter definitions, and select the one you want to become a feature. - -.. note:: Only parameter definitions that have associated a parameter value list can become features. - -To add a new tool, just select the last item under **tool** in the appropriate database, -and enter the name of the tool. - -To add a feature for a particular tool, drag the feature item and drop it over the **tool_feature** list -under the corresponding tool. - -To add a new method for a tool-feature, select the last item under *tool_feature_method* (in the appropriate database), -start typing or press **F2** to display available methods, and select the one you want to add. +Adding entity alternatives +========================== +To add an entity alternative, open the **Entity Alternative** -**Table View**. There under *entity_class_name* select +the class. Under *entity_byname* select the specific entity from that class and from *alternative_name* select the +alternative. Then set the value of the *active* -column to either true or false by double clicking it. The background +of the cells under *entity_class_name* and *database* should become gray, indicating that the entity alternative has +been added. Adding alternatives =================== -From *Alternative tree* -~~~~~~~~~~~~~~~~~~~~~~~ +From **Alternative** +~~~~~~~~~~~~~~~~~~~~ -To add a new alternative, just select the last item appropriate database, -and enter the name of the alternative. +To add a new alternative, just select the last item under the appropriate database, and enter the name of the +new alternative. You can also copy and paste alternatives between different databases. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). To add a new alternative, enter a name in the last cell of the **alternative** header. @@ -260,60 +244,67 @@ To add a new alternative, enter a name in the last cell of the **alternative** h Adding scenarios ================ -From *Scenario tree* -~~~~~~~~~~~~~~~~~~~~ +From **Scenario Tree** +~~~~~~~~~~~~~~~~~~~~~~ To add a new scenario, just select the last item under the appropriate database, and enter the name of the scenario. -To add an alternative for a particular scenario, drag the alternative item from *Alternative tree* +To add an alternative for a particular scenario, drag the alternative item from **Alternative** and drop it under the corresponding scenario. The position where you drop it determines the alternative's *rank* within the scenario. -Alternatives can also be copied from *Alternative tree* -and pasted at the appropriate position in *Scenario tree*. +Alternatives can also be copied from **Alternative** +and pasted at the appropriate position in **Scenario Tree**. + +If it is desirable to base a scenario on an existing one, scenarios can be duplicated +using the **Duplicate** item in the right-click context menu. It is also possible to +copy and paste scenarios between databases. .. note:: Alternatives with higher rank have priority when determining the parameter value for a certain scenario. If the parameter value is specified for two alternatives, and both of them happen to coexist in a same scenario, the value from the alternative with the higher rank takes precedence. -If it is desirable to base a scenario on an existing one, scenarios can be duplicated -using the **Duplicate** item in the right-click context menu, or by pressing **Ctrl+D**. -It is also possible to copy and paste scenarios between databases. +.. note:: As noted in the tooltip, scenario names longer than 20 characters may become shortened in generated files. + This can happen for example when exporting the scenarios using the Exporter -project item. This can lead to confusion + later on if the first 20 characters of the scenario names are identical. Therefore it is recommended to have a unique + identifier for each scenario in the first 20 characters of its name. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). To add a new scenario, enter a name in the last cell of the **scenario** header. -From *Generate scenarios* -~~~~~~~~~~~~~~~~~~~~~~~~~ +From **Generate scenarios** +~~~~~~~~~~~~~~~~~~~~~~~~~~~ Scenarios can be added also by automatically generating them from existing alternatives. -Select the alternatives in *Alternative tree* (using **Ctrl** and **Shift** while clicking the items), +Select the alternatives in **Alternative** (using **Ctrl** and **Shift** while clicking the items), then right click to open a context menu. Select **Generate scenarios...** -.. image:: img/generate_scenarios.png +.. image:: img/generate_scenarios_dialog.png + :width: 300 :align: center Give the scenario names a prefix. An index will be appended to the prefix automatically: **prefix01**, **prefix02**,... -Select appropriate operation from the combo box. +Select appropriate operation from the **Operation** combo box. Checking the **Use base alternative** check box will add the selected alternative to all generated scenarios as the lowest rank alternative. The **Alternative by rank** list allows reordering the ranks of the alternatives. +.. _parameter_value_list: Adding parameter value lists ============================ -To add a new parameter value list, go to *Parameter value list* and select the last item under the appropriate database, -and enter the name of the list. +To add a new parameter value list, go to **Parameter Value List** and select the last item under the appropriate +database, and enter the name of the list. -To add new values for the list, select the last empty item under the corresponding -list item, and enter the value. To enter a complex value, right-click on the empty item and select -**Open editor** from the context menu. +To add new values for the list, expand the list with the right-arrow and select the last empty item under the +corresponding list item, and enter the value. To enter a complex value, right-click on the empty item and select +**Edit...** from the context menu to open the value editor. .. note:: To be actually added to the database, a parameter value list must have at least one value. @@ -321,9 +312,9 @@ list item, and enter the value. To enter a complex value, right-click on the emp Adding metadata and item metadata ================================= -To add new metadata go to *Metadata* and add a new name and value to the last row. +To add new metadata go to **Metadata** and add a new name and value to the last row. To add a new link metadata for an item, select an entity from one of the entity trees or a parameter value from one of the parameter value tables. -Then go to *Item metadata* and select the appropriate metadata name and value on the last row. +Then go to **Item metadata** and select the appropriate metadata name and value on the last row. diff --git a/docs/source/spine_db_editor/committing_and_history.rst b/docs/source/spine_db_editor/committing_and_history.rst new file mode 100644 index 000000000..01fbca937 --- /dev/null +++ b/docs/source/spine_db_editor/committing_and_history.rst @@ -0,0 +1,67 @@ + +.. _committing_and_history: + +Committing and History +====================== + +.. contents:: + :local: + +Committing +---------- + +.. note:: Changes are not immediately saved to the database(s). They need to be committed separately. + +An asterisk (*) in a tab of Spine Database Editor indicates that the session has uncommitted changes: + +.. image:: img/dirty_db.png + :align: center + +If the database is opened from a Data Store, the corresponding project item will also have a notification in its +upper right corner indicating that the database has uncommitted changes. + +To commit your changes, select **Session -> Commit** from the hamburger menu or press **Ctrl+Enter** while the +Spine Database Editor -window is active to open the commit dialog: + +.. image:: img/commit_dialog.png + :align: center + +There is a default text "Updated" readily filled in. It is however good practise to write a short message +that clearly describes what changes have been made to the database. Once the commit message is to you liking, +press **Commit**. Any changes made in the current session will be saved into the database. + +If you try to close a tab with uncommitted changes the following dialog will open: + +.. image:: img/uncommitted_changes_dialog.png + :align: center + +There are a few different options: **Commit and close** will do exactly what it says, **Discard changes and close** +will automatically rollback the changes to the last commit and then close the editor tab. **Cancel** just closes +the dialog and allows you to work on the database again. If you check the box **Do not ask me again** and select +any of the other options beside **Cancel**, the selection you made will be automatically presumed whenever you +close a tab with uncommitted changes. This means that you will not see this dialog again but the changes +will be automatically committed or not depending on the selection you made previously. + +Rollback +-------- + +To undo *all* changes since the last commit, select **Session -> Rollback** from the hamburger menu or press +**Ctrl+Backspace** while the Spine Database Editor -window is active. A dialog confirming the rollback action +will open. From there, select **Rollback** to proceed. + +.. tip:: To undo/redo individual changes, use the **Edit -> Undo** and **Edit -> Redo** actions from the hamburger menu. + +.. note:: After rolling back to the last commit, the changes made in the session will no longer be available + through undo/redo. + +History +------- + +To examine the commit history of the database, select **Session -> History...** from the hamburger menu. +The **commit viewer** will pop up: + +.. image:: img/commit_viewer.png + :align: center + +All the commits made in the database will show here, each includes the timestamp. author and commit message. +By selecting individual commits, the affected items can be inspected in the box on the right. diff --git a/docs/source/spine_db_editor/committing_and_rolling_back.rst b/docs/source/spine_db_editor/committing_and_rolling_back.rst deleted file mode 100644 index 301e9dcaa..000000000 --- a/docs/source/spine_db_editor/committing_and_rolling_back.rst +++ /dev/null @@ -1,18 +0,0 @@ - - -.. _committing_and_rolling_back: - -Committing and rolling back ---------------------------- - -.. contents:: - :local: - -.. note:: Changes are not immediately saved to the database(s). They need to be committed separately. - -To commit your changes, select **Session -> Commit** from the hamburger menu, enter a commit message and press **Commit**. -Any changes made in the current session will be saved into the database. - -To undo *all* changes since the last commit, select **Session -> Rollback** from the hamburger menu. - -.. tip:: To undo/redo individual changes, use the **Edit -> Undo** and **Edit -> Redo** actions from the hamburger menu. diff --git a/docs/source/spine_db_editor/getting_started.rst b/docs/source/spine_db_editor/getting_started.rst index 49c63b195..238c821a6 100644 --- a/docs/source/spine_db_editor/getting_started.rst +++ b/docs/source/spine_db_editor/getting_started.rst @@ -1,7 +1,15 @@ +.. |reload| image:: ../../../spinetoolbox/ui/resources/menu_icons/sync.svg + :width: 16 +.. |database| image:: ../../../spinetoolbox/ui/resources/database.svg + :width: 16 + *************** Getting started *************** +This section gives a short outline on how to get started using the editor and how to navigate the ui. +Information about the settings for the editor can be found in :ref:`DB editor settings`. + .. contents:: :local: @@ -11,49 +19,188 @@ Launching the editor From Spine Toolbox ================== -To open a single database in Spine database editor: +There are two different ways to open a single database in Spine database editor from Spine Toolbox: + +Using a *Data Store* project item: 1. Create a *Data Store* project item. 2. Select the *Data Store*. 3. Enter the url of the database in *Data Store Properties*. 4. Press the **Open editor...** button in *Data Store Properties* or double-click the *Data Store* project item. -To open multiple SQLite databases in Spine database editor: +Without a *Data Store*: -1. Open a database in Database editor as explained above. -2. Select **Add...** from the ☰ menu. -3. Open the SQLite file. +1. From the main window select **File -> New DB Editor**. +2. Open the menu by clicking on the hamburger menu icon (☰) or by pressing **ALT+F** or **ALT+E**. +3. Select **Open...** to open an existing database, **New..** to create a new one or paste a database URL into + the URL bar. From the command line ===================== To open a single database in Spine database editor, use the ``spine-db-editor`` -application which comes with Spine Toolbox:: +application which comes with Spine Toolbox. After the virtual environment is activated +the editor can be opened with the following command:: + + spine-db-editor "...url of the database..." + +Note that for e.g. an SQLite database, the url should start with ``sqlite:///`` followed by the path. - spine-db-editor "...url of the database..." +Adding multiple databases to one editor +--------------------------------------- -Note that for e.g. an SQLite database, the url should start with ‘sqlite:’. +It is possible to open multiple databases in the same editor. This allows one to view and modify +the data of the open databases in one editor. +To open multiple SQLite databases in the same Spine database editor by file browser: + +1. Open a database Database editor using any of the ways explained before. +2. Select **Add...** from the editor's hamburger menu (☰). +3. Browse to the directory of the SQLite file and open it. + +By using the database URL: + +1. Open a database Database editor using any of the ways explained before. +2. In the URL bar, after the already open database's URL add a semicolon ``;`` + and after that the URL of the other database to be opened in the same editor. Knowing the UI -------------- -The form has the following main UI components: - -- *Entity trees* (*Object tree* and *Relationship tree*): - they present the structure of classes and entities in all databases in the shape of a tree. -- *Stacked tables* (*Object parameter value*, *Object parameter definition*, - *Relationship parameter value*, and *Relationship parameter definition*): - they present object and relationship parameter data in the form of stacked tables. +When you open an empty database for the first time in a Spine Database Editor, it should look something +like this: + +.. image:: img/plain_db_editor.png + :align: center + +The dock widgets can be scaled by dragging them from the sides and moved around by dragging them from their +darker colored headers. Like with other widgets, Toolbox remembers the customizations and the editor will +open in the same configuration when it is opened the next time. The dock configurations are URL specific. +the configurations for the URL can be restored back to default from the hamburger menu **View->Docks...->Reset docks**. + +Tab bar +======= + +The uppermost UI element is the tab bar. One editor window can have multiple tabs. New tabs can be added by +pressing the plus-sign (**+**) in the tab bar. In the newly created tab, databases can be opened once again +with the instructions given above. Tabs can be deleted from the editor by pressing the cross (**X**) inside +a tab. The tabs can be dragged from the tab bar to create new editor windows. Tabs from different windows +can also be dragged into others, fusing them into the same editor window. + +Navigation bar +============== + +Right below the tab bar there is the navigation bar. With the backwards and forwards arrows it is possible +to go back to the database that was previously loaded in the specific tab. This is kind of analogous of web +browsers and going back to the previous page. Next to the arrows there is the **reload** (|reload|) button. +It can be used to reload the data of the database. Next up is the Data Store icon (|database|) which lists +the Data Store items in the project and can be used to open any of them in the current tab. The URL bar +contains the URL of the databases tha are currently open in the tab. As mentioned before, databases can +be opened by inserting valid database URLs into this field and pressing enter. The URL bar also contains +the filter (more about this later). After the URL bar there is the Spine-Toolbox logo which when clicked +brings up the Spine-Toolbox main window. Finally there is the hamburger menu (☰) which holds many of the +functionalities of the Spine Database Editor. + +Hamburger menu +============== + +The hamburger menu (☰) can be located in the upper left corner of the Spine Database Editor. It is the place +where options and other such things can be found. + +File +~~~~ + +The uppermost section in the menu is dedicated to actions related +to databases. There you can create a new Spine db from **New...**, open an existing one from **Open...** or add +another database to the current tab form **Add...** as explained before. There are also options **Import...**, +**Export...** and **Export session...**. The importing works kind of like adding another database to the existing +tab but instead of just opening the other database it brings all of the data from the other database and merges it +into the current database. With export it is possible to export the current database into it's own ``.sqlite`` file. +The export session works just like export but instead of exporting the whole database, it exports just the new +modifications that have been made since the last commit. + +Edit +~~~~ + +In the **Edit** section there lies the **Undo** and **Redo** -buttons. These can be used to undo and redo the +actions that have been made in the editor (**CTR+Z** and **CTR+Y** also work). The **Copy name(s) as text** allows +the user to copy items into the clipboard that can then be pasted elsewhere. The **Paste** option does +exactly what it says, it pastes the data on the clipboard into the selected field(s). The **Purge...** button is +quite useful when there is a need to get rid of a lot of data quickly. Clicking it will open a new window where +options for the purging are given. Find out more about purging in the section :ref:`Removing data`. +The **Vacuum** option tries to free up some memory from the claws of the database. + +View +~~~~ + +The different view modes are listed in the **View** -section. Also the **Docks...** button for managing the +visibility of the UI elements is located here. When switching to the **Value**, **Index** and **Element** views +something need to selected from the entity tree in order for the view to show anything meaningful. The Graph view +will show an graphical representation of the entities whereas the table view shows the plain data in table format. +By pressing the **Docks...** one can customize what UI elements are displayed. This way it is possible to for example +have the graph and scenario pivot table views open at the same time. + +Session +~~~~~~~ + +The **Commit..** button is for committing the changes in the database. Pressing the button will open up a commit +dialog box, where a commit message can be written. The default commit message is just "Updated" but it is good +practise to write descriptive and concise messages. The **Rollback** button reverts the database to the state +it was in when it was committed the last time. This means that all modifications to the data that haven't been +committed will be lost. It is also good to note that this action clears the undo/redo stack which means that the +operation is irreversible. The **History** button allows one to view the commit history of the database. + +Other +~~~~~ + +In the bottom part of the hamburger menu there is a button to open the User Guide in a web browser, **Settings** +button to open the Spine Database Editor settings and a **Close** button for closing the editor. More information +about the settings can be found in :ref:`DB editor settings`. + +Filter +====== + +The filter can be used to select which items are allowed to be shown in the editor. The filter is based on scenarios. +By pressing the filter image in the right end of the URL bar, the filter selector widget opens up. There the desired +scenario can be selected. When a selection is made and the **Update filters** button is pressed, the changes will be +applied to the editor. Now all entities, parameters etc. will be filtered out if they don't belong to the scenario +specified in the filter. + +.. tip:: Note that after applying the filter, the URL gets updated with some additional information about the filters. + It is therefore possible to make changes to the filtering just by modifying the URL from the URL bar. + +Undo and redo +============= + +Whenever changes are made to the data in the Spine Database Editor, the changes get stored into memory. This +allows undoing and redoing the operations made in the editor. Buttons for these operations can be found in the +hamburger menu and the usual shortcuts **Ctrl+Z** and **Ctrl+Y** work also. However if the changes are committed, +the memory for the changes gets cleared meaning that the changes before the commit can't be undone anymore. + +Views and trees +=============== + +Spine Database Editor has the following main UI components: + +- *Entity tree*: + they present the structure of entities in all databases in the shape of a tree. +- *Table views* (*Parameter value*, *Parameter definition*, *Entity alternative*): + they present entity data in the form of stacked tables. - *Pivot table* and *Frozen table*: they present data in the form of a pivot table, optionally with frozen dimensions. -- *Entity graph*: it presents the structure of classes and entities in the shape of a graph. -- *Tool/Feature tree*: it presents tools, features, and methods defined in the databases. +- *Graph view*: it presents the structure of classes and entities in the shape of a graph. - *Parameter value list*: it presents parameter value lists available in the databases. -- *Alternative tree*: it presents alternatives defined in the databases. -- *Scenario tree*: it presents scenarios defined in the databases. +- *Alternative*: it presents alternatives defined in the databases in the shape of a tree. +- *Scenario tree*: it presents scenarios defined in the databases in the shape of a tree. - *Metadata*: presents metadata defined in the databases. - *Item metadata*: shows metadata associated with the currently selected entities or parameter values. -.. tip:: You can customize the UI from the **View** and **Pivot** sections in the hamburger ☰ menu. +.. tip:: You can customize the UI from the **View** section in the hamburger ☰ menu. There the **Docks...** + menu can be used to enable and disable the different UI components listed above. + +Items from the trees can be selected by clicking them with the left mouse button and the views will react to +the changes. By default, multiple items can be selected at the same time across the trees by holding down **Ctrl** +while making the selections. This behavior can be flipped from the editor settings (**Ctrl+,**) by toggling the +*Sticky selection* -setting. +In the next section you will learn more about the different UI components and views available in the editor diff --git a/docs/source/spine_db_editor/img/EAV_CR.png b/docs/source/spine_db_editor/img/EAV_CR.png deleted file mode 100644 index 75e07e884..000000000 Binary files a/docs/source/spine_db_editor/img/EAV_CR.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/add_entities_dialog.png b/docs/source/spine_db_editor/img/add_entities_dialog.png new file mode 100644 index 000000000..83bf59f91 Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entities_dialog.png differ diff --git a/docs/source/spine_db_editor/img/add_entities_dialog_2D.png b/docs/source/spine_db_editor/img/add_entities_dialog_2D.png new file mode 100644 index 000000000..0b4b47254 Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entities_dialog_2D.png differ diff --git a/docs/source/spine_db_editor/img/add_entity_classes_dialog.png b/docs/source/spine_db_editor/img/add_entity_classes_dialog.png new file mode 100644 index 000000000..ccfcbdd14 Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_classes_dialog.png differ diff --git a/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png b/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png new file mode 100644 index 000000000..63125c01c Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_classes_dialog_2D.png differ diff --git a/docs/source/spine_db_editor/img/add_entity_group_dialog.png b/docs/source/spine_db_editor/img/add_entity_group_dialog.png new file mode 100644 index 000000000..150b89c60 Binary files /dev/null and b/docs/source/spine_db_editor/img/add_entity_group_dialog.png differ diff --git a/docs/source/spine_db_editor/img/add_object_classes_dialog.png b/docs/source/spine_db_editor/img/add_object_classes_dialog.png deleted file mode 100644 index 3696824dc..000000000 Binary files a/docs/source/spine_db_editor/img/add_object_classes_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/add_object_group_dialog.png b/docs/source/spine_db_editor/img/add_object_group_dialog.png deleted file mode 100644 index 76a58354f..000000000 Binary files a/docs/source/spine_db_editor/img/add_object_group_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/add_objects_dialog.png b/docs/source/spine_db_editor/img/add_objects_dialog.png deleted file mode 100644 index 5206888ec..000000000 Binary files a/docs/source/spine_db_editor/img/add_objects_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png b/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png deleted file mode 100644 index d1e74ad1d..000000000 Binary files a/docs/source/spine_db_editor/img/add_relationship_classes_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/add_relationships_dialog.png b/docs/source/spine_db_editor/img/add_relationships_dialog.png deleted file mode 100644 index dbdf642d2..000000000 Binary files a/docs/source/spine_db_editor/img/add_relationships_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/alternative_tree.png b/docs/source/spine_db_editor/img/alternative_tree.png index 228dfba5a..d4e2c5b69 100644 Binary files a/docs/source/spine_db_editor/img/alternative_tree.png and b/docs/source/spine_db_editor/img/alternative_tree.png differ diff --git a/docs/source/spine_db_editor/img/commit_dialog.png b/docs/source/spine_db_editor/img/commit_dialog.png new file mode 100644 index 000000000..967da5553 Binary files /dev/null and b/docs/source/spine_db_editor/img/commit_dialog.png differ diff --git a/docs/source/spine_db_editor/img/commit_viewer.png b/docs/source/spine_db_editor/img/commit_viewer.png new file mode 100644 index 000000000..cd940c20e Binary files /dev/null and b/docs/source/spine_db_editor/img/commit_viewer.png differ diff --git a/docs/source/spine_db_editor/img/cs-c2.png b/docs/source/spine_db_editor/img/cs-c2.png deleted file mode 100644 index 202a88388..000000000 Binary files a/docs/source/spine_db_editor/img/cs-c2.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/dirty_db.png b/docs/source/spine_db_editor/img/dirty_db.png new file mode 100644 index 000000000..5d8cce613 Binary files /dev/null and b/docs/source/spine_db_editor/img/dirty_db.png differ diff --git a/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png b/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png new file mode 100644 index 000000000..80fcbd9c9 Binary files /dev/null and b/docs/source/spine_db_editor/img/edit_entity_classes_dialog.png differ diff --git a/docs/source/spine_db_editor/img/edit_object_classes_dialog.png b/docs/source/spine_db_editor/img/edit_object_classes_dialog.png deleted file mode 100644 index 956576382..000000000 Binary files a/docs/source/spine_db_editor/img/edit_object_classes_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/entity_alternative_table.png b/docs/source/spine_db_editor/img/entity_alternative_table.png new file mode 100644 index 000000000..cdb271b04 Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_alternative_table.png differ diff --git a/docs/source/spine_db_editor/img/entity_graph.png b/docs/source/spine_db_editor/img/entity_graph.png index b70fcc256..061dc7dbc 100644 Binary files a/docs/source/spine_db_editor/img/entity_graph.png and b/docs/source/spine_db_editor/img/entity_graph.png differ diff --git a/docs/source/spine_db_editor/img/entity_icon_editor.png b/docs/source/spine_db_editor/img/entity_icon_editor.png new file mode 100644 index 000000000..6abd5c95d Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_icon_editor.png differ diff --git a/docs/source/spine_db_editor/img/entity_name_filter_menu.png b/docs/source/spine_db_editor/img/entity_name_filter_menu.png new file mode 100644 index 000000000..a13d7a9c7 Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_name_filter_menu.png differ diff --git a/docs/source/spine_db_editor/img/entity_parameter_value_table.png b/docs/source/spine_db_editor/img/entity_parameter_value_table.png new file mode 100644 index 000000000..1c007f122 Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_parameter_value_table.png differ diff --git a/docs/source/spine_db_editor/img/entity_tree.png b/docs/source/spine_db_editor/img/entity_tree.png new file mode 100644 index 000000000..4d7da96cf Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_tree.png differ diff --git a/docs/source/spine_db_editor/img/entity_tree_context_menu.png b/docs/source/spine_db_editor/img/entity_tree_context_menu.png new file mode 100644 index 000000000..3f649232d Binary files /dev/null and b/docs/source/spine_db_editor/img/entity_tree_context_menu.png differ diff --git a/docs/source/spine_db_editor/img/excel_entity_sheet.png b/docs/source/spine_db_editor/img/excel_entity_sheet.png new file mode 100644 index 000000000..4776075c6 Binary files /dev/null and b/docs/source/spine_db_editor/img/excel_entity_sheet.png differ diff --git a/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png new file mode 100644 index 000000000..8138cc6d7 Binary files /dev/null and b/docs/source/spine_db_editor/img/excel_entity_sheet_timeseries.png differ diff --git a/docs/source/spine_db_editor/img/excel_object_sheet.png b/docs/source/spine_db_editor/img/excel_object_sheet.png deleted file mode 100644 index b237bdc25..000000000 Binary files a/docs/source/spine_db_editor/img/excel_object_sheet.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png deleted file mode 100644 index ad07e8da1..000000000 Binary files a/docs/source/spine_db_editor/img/excel_object_sheet_timeseries.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/excel_relationship_sheet.png b/docs/source/spine_db_editor/img/excel_relationship_sheet.png deleted file mode 100644 index a19e4cf12..000000000 Binary files a/docs/source/spine_db_editor/img/excel_relationship_sheet.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png b/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png deleted file mode 100644 index aaa097339..000000000 Binary files a/docs/source/spine_db_editor/img/excel_relationship_sheet_timeseries.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/export_bar.png b/docs/source/spine_db_editor/img/export_bar.png new file mode 100644 index 000000000..4de3bb2a9 Binary files /dev/null and b/docs/source/spine_db_editor/img/export_bar.png differ diff --git a/docs/source/spine_db_editor/img/generate_scenarios.png b/docs/source/spine_db_editor/img/generate_scenarios.png deleted file mode 100644 index 271ee2fd4..000000000 Binary files a/docs/source/spine_db_editor/img/generate_scenarios.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/generate_scenarios_dialog.png b/docs/source/spine_db_editor/img/generate_scenarios_dialog.png new file mode 100644 index 000000000..039bc48d5 Binary files /dev/null and b/docs/source/spine_db_editor/img/generate_scenarios_dialog.png differ diff --git a/docs/source/spine_db_editor/img/manage_entities_dialog.png b/docs/source/spine_db_editor/img/manage_entities_dialog.png new file mode 100644 index 000000000..1a92b4ec6 Binary files /dev/null and b/docs/source/spine_db_editor/img/manage_entities_dialog.png differ diff --git a/docs/source/spine_db_editor/img/manage_members_dialog.png b/docs/source/spine_db_editor/img/manage_members_dialog.png index 2dfd1b30f..472cce603 100644 Binary files a/docs/source/spine_db_editor/img/manage_members_dialog.png and b/docs/source/spine_db_editor/img/manage_members_dialog.png differ diff --git a/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png b/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png deleted file mode 100644 index 1e8dc4d71..000000000 Binary files a/docs/source/spine_db_editor/img/manage_parameter_tags_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/manage_relationships_dialog.png b/docs/source/spine_db_editor/img/manage_relationships_dialog.png deleted file mode 100644 index 981966855..000000000 Binary files a/docs/source/spine_db_editor/img/manage_relationships_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/mass_export_items_dialog.png b/docs/source/spine_db_editor/img/mass_export_items_dialog.png index 156ca1ae4..511fa3f37 100644 Binary files a/docs/source/spine_db_editor/img/mass_export_items_dialog.png and b/docs/source/spine_db_editor/img/mass_export_items_dialog.png differ diff --git a/docs/source/spine_db_editor/img/mass_remove_items_dialog.png b/docs/source/spine_db_editor/img/mass_remove_items_dialog.png deleted file mode 100644 index 8d0dee18c..000000000 Binary files a/docs/source/spine_db_editor/img/mass_remove_items_dialog.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/object_name_filter_menu.png b/docs/source/spine_db_editor/img/object_name_filter_menu.png deleted file mode 100644 index 61039b6af..000000000 Binary files a/docs/source/spine_db_editor/img/object_name_filter_menu.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/object_parameter_value_table.png b/docs/source/spine_db_editor/img/object_parameter_value_table.png deleted file mode 100644 index ffe42e265..000000000 Binary files a/docs/source/spine_db_editor/img/object_parameter_value_table.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/object_tree.png b/docs/source/spine_db_editor/img/object_tree.png deleted file mode 100644 index 5bd3073ae..000000000 Binary files a/docs/source/spine_db_editor/img/object_tree.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/parameter_tag.png b/docs/source/spine_db_editor/img/parameter_tag.png deleted file mode 100644 index 5700da0df..000000000 Binary files a/docs/source/spine_db_editor/img/parameter_tag.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/parameter_value_list.png b/docs/source/spine_db_editor/img/parameter_value_list.png index ccc453b77..23c59091b 100644 Binary files a/docs/source/spine_db_editor/img/parameter_value_list.png and b/docs/source/spine_db_editor/img/parameter_value_list.png differ diff --git a/docs/source/spine_db_editor/img/pivot_table.png b/docs/source/spine_db_editor/img/pivot_table.png index 7c61f1008..a42fcb4c4 100644 Binary files a/docs/source/spine_db_editor/img/pivot_table.png and b/docs/source/spine_db_editor/img/pivot_table.png differ diff --git a/docs/source/spine_db_editor/img/plain_db_editor.png b/docs/source/spine_db_editor/img/plain_db_editor.png new file mode 100644 index 000000000..19c1c1947 Binary files /dev/null and b/docs/source/spine_db_editor/img/plain_db_editor.png differ diff --git a/docs/source/spine_db_editor/img/purge_dialog.png b/docs/source/spine_db_editor/img/purge_dialog.png new file mode 100644 index 000000000..1522484a9 Binary files /dev/null and b/docs/source/spine_db_editor/img/purge_dialog.png differ diff --git a/docs/source/spine_db_editor/img/remove_entities_dialog.png b/docs/source/spine_db_editor/img/remove_entities_dialog.png index 7d4d16ac8..adb5c8290 100644 Binary files a/docs/source/spine_db_editor/img/remove_entities_dialog.png and b/docs/source/spine_db_editor/img/remove_entities_dialog.png differ diff --git a/docs/source/spine_db_editor/img/scenario_tree.png b/docs/source/spine_db_editor/img/scenario_tree.png index 2b557bbd3..d9451a78d 100644 Binary files a/docs/source/spine_db_editor/img/scenario_tree.png and b/docs/source/spine_db_editor/img/scenario_tree.png differ diff --git a/docs/source/spine_db_editor/img/tool_feature_tree.png b/docs/source/spine_db_editor/img/tool_feature_tree.png deleted file mode 100644 index b7730fb9b..000000000 Binary files a/docs/source/spine_db_editor/img/tool_feature_tree.png and /dev/null differ diff --git a/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png b/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png new file mode 100644 index 000000000..1eb44b4a6 Binary files /dev/null and b/docs/source/spine_db_editor/img/uncommitted_changes_dialog.png differ diff --git a/docs/source/spine_db_editor/importing_and_exporting_data.rst b/docs/source/spine_db_editor/importing_and_exporting_data.rst index 97797b44f..67f0278cf 100644 --- a/docs/source/spine_db_editor/importing_and_exporting_data.rst +++ b/docs/source/spine_db_editor/importing_and_exporting_data.rst @@ -10,235 +10,262 @@ This section describes the available tools to import and export data. Overview ======== -Spine database editor supports importing and exporting data in three different formats: SQLite, JSON, and Excel. -The SQLite import/export uses the Spine database format. -The JSON and Excel import/export use a specific format described below. +Spine Database Editor supports importing and exporting data in three different formats: SQLite, JSON, and Excel. +The SQLite import/export uses the Spine Database format. The JSON and Excel import/export use a specific format +described in :ref:`format_specifications`. + +Importing +========= + +To import a file, select **File --> Import** from the hamburger menu. +The *Import file* dialog will pop up. +Select the file type (SQLite, JSON, or Excel), enter the path of the file to import, and accept the dialog. + +.. tip:: You can undo import operations using **Edit -> Undo**. + +Exporting +========= + +Mass export +~~~~~~~~~~~ + +To export items in mass, select **File --> Export** from the hamburger menu. +The *Export items* dialog will pop up: + +.. image:: img/mass_export_items_dialog.png + :align: center + +Select the databases you want to export under *Databases*, and the type of items under *Items*, +then press **Ok**. +The *Export file* dialog will pop up now. +Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. + + +Selective export +~~~~~~~~~~~~~~~~ + +To export a specific subset of items, select the corresponding items in the **Entity Tree**, +right click on the selection to bring the context menu, and select **Export**. + +The *Export file* dialog will pop up. +Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. +Session export +~~~~~~~~~~~~~~ + +To export only uncommitted changes made in the current session, select **File --> Export session** from +the hamburger menu. + +The *Export file* dialog will pop up. +Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. + +.. note:: Export operations include all uncommitted changes. + + +Accessing/using exported files +============================== + +Whenever you successfully export a file, +a button with the file name is created in the *Exports* bar at the bottom of the form. +Pressing that button will open the JSON or Excel file with the default program that your +system associates with that filetype. Exports of SQLite file type will be opened in a new tab +of the Spine Database Editor. To open the folder containing the export, click on the arrow next +to the file name and select **Open containing folder** from the popup menu. + + .. image:: img/export_bar.png + :align: center + +.. _format_specifications: + +Format specifications +===================== + .. tip:: To create a template file with the JSON or Excel format you can simply export an existing Spine database into one of those formats. Excel format ~~~~~~~~~~~~ +.. note:: Excel exports are not comprehensive. Even though every type of item is selectable in + the exporting selection, sheets will be generated only for some of the selections. + Things like metadata and parameter value lists don't currently have export support with excel. + The JSON export on the other hand is comprehensive and will export every detail about the + database. -The Excel format consists of one sheet per object and relationship class. -Each sheet can have one of four different formats: +When parameter values are exported, the generated Excel will have every entity class on its own sheet. +If the entity has indexed values (time-series, map etc.) as well as single values (floats, strings etc.) +the entity will have more than one sheet, one containing the single values and others that unpack the +indexed values: -1. Object class with scalar parameter data: +scalar parameter data: - .. image:: img/excel_object_sheet.png + .. image:: img/excel_entity_sheet.png :align: center -2. Object class with indexed parameter data: +indexed parameter data: - .. image:: img/excel_object_sheet_timeseries.png + .. image:: img/excel_entity_sheet_timeseries.png :align: center -3. Relationship class with scalar parameter data: - .. image:: img/excel_relationship_sheet.png - :align: center +JSON format +~~~~~~~~~~~ -4. Relationship class with indexed parameter data: +The JSON export is complete since it contains all of the data from the database. +The JSON format consists of a single JSON object with the following ``OPTIONAL`` keys: - .. image:: img/excel_relationship_sheet_timeseries.png - :align: center +- **entity_classes**: the value of this key ``MUST`` be a JSON array, + representing a list of entity classes. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements: + - The first element ``MUST`` be a JSON string, indicating the entity class name. + - The second element ``MUST`` be a JSON array, indicating the member entity classes. Each element in + this array ``MUST`` be a JSON string, indicating the entity class name. In case of 0-D entity class, + the array is empty. + - The third element ``MUST`` be either a JSON string, indicating the entity class description, or null. + - The fourth element ``MUST`` be either a JSON integer, indicating the entity class icon code, or null. + - The fourth element ``MUST`` be a JSON boolean, indicating the state of active by default. -JSON format -~~~~~~~~~~~ +- **superclass_subclasses**: the value of this key ``MUST`` be a JSON array, + representing a list of superclasses. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements: -The JSON format consists of a single JSON object with the following ``OPTIONAL`` keys: + - The first element ``MUST`` be a JSON string, indicating the superclass name. + - The second element ``MUST`` be a JSON string, indicating the subclass name. -- **object_classes**: the value of this key ``MUST`` be a JSON array, - representing a list of object classes. +- **entities**: the value of this key ``MUST`` be a JSON array, + representing a list of entities. Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements: - - The first element ``MUST`` be a JSON string, indicating the object class name. - - The second element ``MUST`` be either a JSON string, indicating the object class description, or null. - - The third element ``MUST`` be either a JSON integer, indicating the object class icon code, or null. + - The first element ``MUST`` be a JSON string, indicating the entity class name. + - The second element ``MUST`` be a JSON array, if the entity is N-dimensional. In this case each element in + the array ``MUST`` be a JSON string itself, each being an element of the entity. If the entity class is 0-D, + this element ``MUST`` be a JSON string, indicating the entity name. + - The third element ``MUST`` be either a JSON string, indicating the entity description, or null. + +- **Entity alternatives**: the value of this key ``MUST`` be a JSON array, + representing a list of entity alternatives. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements: -- **relationship_classes**: the value of this key ``MUST`` be a JSON array, - representing a list of relationships classes. + - The first element ``MUST`` be a JSON string, indicating the entity class name. + - The second element ``MUST`` be either a JSON array or a JSON string. In the case of a N-dimensional entity + the array ``MUST`` itself contain JSON strings representing the element name list of the entity. + If the entity is 0-D, a JSON string of the name of the entity is enough, but also a JSON array of one element + is supported. + +- **entity_groups**: the value of this key ``MUST`` be a JSON array, + representing a list of entity groups. Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements: - - The first element ``MUST`` be a JSON string, indicating the relationship class name. - - The second element ``MUST`` be a JSON array, indicating the member object classes. - Each element in this array ``MUST`` be a JSON string, indicating the object class name. - - The third element ``MUST`` be either a JSON string, indicating the relationship class description, or null. + - The first element ``MUST`` be a JSON string, indicating the entity class. + - The second element ``MUST`` be a JSON string, indicating the entity group name. + - The third element ``MUST`` be a JSON string, indicating the member entity's name. - **parameter_value_lists**: the value of this key ``MUST`` be a JSON array, representing a list of parameter value lists. Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements: - The first element ``MUST`` be a JSON string, indicating the parameter value list name. - - The second element ``MUST`` be a JSON array, indicating the values in the list. - Each element in this array ``MUST`` be either a JSON object, string, number, or null, + - The second element ``MUST`` be either a JSON object, string, number, or null, indicating the value. -- **object_parameters**: the value of this key ``MUST`` be a JSON array, - representing a list of object parameter definitions. +- **parameter_definitions**: the value of this key ``MUST`` be a JSON array, + representing a list of parameter definitions. Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have five elements: - - The first element ``MUST`` be a JSON string, indicating the object class name. + - The first element ``MUST`` be a JSON string, indicating the entity class name. - The second element ``MUST`` be a JSON string, indicating the parameter name. - The third element ``MUST`` be either a JSON object, string, number, or null, indicating the parameter default value. - The fourth element ``MUST`` be a JSON string, indicating the associated parameter value list, or null. - - The last element ``MUST`` be either a JSON string, indicating the parameter description, or null. - -- **relationship_parameters**: the value of this key ``MUST`` be a JSON array, - representing a list of relationship parameter definitions. - Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have five elements: - - - The first element ``MUST`` be a JSON string, indicating the relationship class name. - - The second element ``MUST`` be a JSON string, indicating the parameter name. - - The third element ``MUST`` be either a JSON object, string, number, or null, - indicating the parameter default value. - - The fourth element ``MUST`` be a JSON string, indicating the associated parameter value list, or null - - The last element ``MUST`` be either a JSON string, indicating the parameter description, or null. - -- **objects**: the value of this key ``MUST`` be a JSON array, - representing a list of objects. - Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements: - - - The first element ``MUST`` be a JSON string, indicating the object class name. - - The second element ``MUST`` be a JSON string, indicating the object name. - - The third element ``MUST`` be either a JSON string, indicating the object description, or null. - -- **relationships**: the value of this key ``MUST`` be a JSON array, - representing a list of relationships. - Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements: - - - The first element ``MUST`` be a JSON string, indicating the relationship class name. - - The second element ``MUST`` be a JSON array, indicating the member objects. - Each element in this array ``MUST`` be a JSON string, indicating the object name. - -- **object_parameter_values**: the value of this key ``MUST`` be a JSON array, - representing a list of object parameter values. - Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements: - - - The first element ``MUST`` be a JSON string, indicating the object class name. - - The second element ``MUST`` be a JSON string, indicating the object name. - - The third element ``MUST`` be a JSON string, indicating the parameter name. - - The fourth element ``MUST`` be either a JSON object, string, number, or null, - indicating the parameter value. + - The fifth element ``MUST`` be either a JSON string, indicating the parameter description, or null. -- **relationship_parameter_values**: the value of this key ``MUST`` be a JSON array, - representing a list of relationship parameter values. +- **parameter_values**: the value of this key ``MUST`` be a JSON array, + representing a list of entity parameter values. Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have four elements: - - The first element ``MUST`` be a JSON string, indicating the relationship class name. - - The second element ``MUST`` be a JSON array, indicating the relationship's member objects. - Each element in this array ``MUST`` be a JSON string, indicating the object name. + - The first element ``MUST`` be a JSON string, indicating the entity class name. + - The second element ``MUST`` be a JSON array, if the entity is N-dimensional. In this case each element in + the array ``MUST`` be a JSON string itself, each being an element of the entity. If the entity class is 0-D, + this element ``MUST`` be a JSON string, indicating the entity name. - The third element ``MUST`` be a JSON string, indicating the parameter name. - The fourth element ``MUST`` be either a JSON object, string, number, or null, indicating the parameter value. -Example:: + There is one ``OPTIONAL`` element: - { - "object_classes": [ - ["connection", "An entity where an energy transfer takes place", 280378317271233], - ["node", "An entity where an energy balance takes place", 280740554077951], - ["unit", "An entity where an energy conversion process takes place", 281470681805429], - ], - "relationship_classes": [ - ["connection__node__node", ["connection", "node", "node"] , null], - ["unit__from_node", ["unit", "node"], null], - ["unit__to_node", ["unit", "node"], null], - ], - "parameter_value_lists": [ - ["balance_type_list", ["\"balance_type_node\"", "\"balance_type_group\"", "\"balance_type_none\""]], - ["truth_value_list", ["\"value_false\"", "\"value_true\""]], - ], - "object_parameters": [ - ["connection", "connection_availability_factor", 1.0, null, null], - ["node", "balance_type", "balance_type_node", "balance_type_list", null], - ], - "relationship_parameters": [ - ["connection__node__node", "connection_flow_delay", {"type": "duration", "data": "0h"}, null, null], - ["unit__from_node", "unit_capacity", null, null, null], - ["unit__to_node", "unit_capacity", null, null, null], - ], - "objects": [ - ["connection", "Bastusel_to_Grytfors_disch", null], - ["node", "Bastusel_lower", null], - ["node", "Bastusel_upper", null], - ["node", "Grytfors_upper", null], - ["unit", "Bastusel_pwr_plant", null], - ], - "relationships": [ - ["connection__node__node", ["Bastusel_to_Grytfors_disch", "Grytfors_upper", "Bastusel_lower"]], - ["unit__from_node", ["Bastusel_pwr_plant", "Bastusel_upper"]], - ["unit__to_node", ["Bastusel_pwr_plant", "Bastusel_lower"]], - ], - "object_parameter_values": [ - ["node", "Bastusel_upper", "demand", -0.2579768519], - ["node", "Bastusel_upper", "fix_node_state", {"type": "time_series", "data": {"2018-12-31T23:00:00": 5581.44, "2019-01-07T23:00:00": 5417.28}}], - ["node", "Bastusel_upper", "has_state", "value_true"], - ], - "relationship_parameter_values": [ - ["connection__node__node", ["Bastusel_to_Grytfors_disch", "Grytfors_upper", "Bastusel_lower"], "connection_flow_delay", {"type": "duration", "data": "1h"}], - ["unit__from_node", ["Bastusel_pwr_plant", "Bastusel_upper"], "unit_capacity", 127.5], - ] - } + - The fifth element ``MUST`` either be a JSON string indicating the alternative, or null. If this element + is not present, an alternative named Base will be created if it doesn't exist and the values will be set + in that alternative. -Importing -========= - -To import a file, select **File --> Import** from the hamburger menu. -The *Import file* dialog will pop up. -Select the file type (SQLite, JSON, or Excel), enter the path of the file to import, and accept the dialog. - -.. tip:: You can undo import operations using **Edit -> Undo**. - -Exporting -========= - -Mass export -~~~~~~~~~~~ - -To export items in mass, select **File --> Export** from the hamburger menu. -The *Export items* dialog will pop up: - -.. image:: img/mass_export_items_dialog.png - :align: center - -Select the databases you want to export under *Databases*, and the type of items under *Items*, -then press **Ok**. -The *Export file* dialog will pop up now. -Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. - - -Selective export -~~~~~~~~~~~~~~~~ +- **alternatives**: the value of this key ``MUST`` be a JSON array, + representing a list of alternatives. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements: -To export a specific subset of items, select the corresponding items in either *Object tree* -and *Relationship tree*, right click on the selection to bring the context menu, -and select **Export**. - -The *Export file* dialog will pop up. -Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. + - The first element ``MUST`` be a JSON string, indicating the alternative name + - The second element ``MUST`` be either a JSON string, indicating the alternative description, or null. +- **scenarios**: the value of this key ``MUST`` be a JSON array, + representing a list of alternatives. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have two elements: -Session export -~~~~~~~~~~~~~~ + - The first element ``MUST`` be a JSON string, indicating the scenario name. + - The second element ``MUST`` be a JSON boolean, indicating the scenario alternative active state. + - The third element ``MUST`` be either a JSON string, indicating the scenario description, or null. -To export only uncommitted changes made in the current session, select **File --> Export session** from -the hamburger menu. +- **scenario alternatives**: the value of this key ``MUST`` be a JSON array, + representing a list of alternatives. + Each element in this array ``MUST`` be itself a JSON array and ``MUST`` have three elements: -The *Export file* dialog will pop up. -Select the file type (SQLite, JSON, or Excel), enter the path of the file to export, and accept the dialog. + - The first element ``MUST`` be a JSON string, indicating the scenario name. + - The second element ``MUST`` be a JSON string, indicating the alternative name aowfiuhwaofiajw. -.. note:: Export operations include all uncommitted changes. -Accessing/using exported files -============================== +Example:: -Whenever you successfully export a file, -a button with the file name is created in the *Exports* bar at the bottom of the form. -To open the file in your registered program, press that button. -To open the containing folder, -click on the arrow next to the file name and select **Open containing folder** from the popup menu. \ No newline at end of file + { + "entity_classes": [ + ["connection",[],"A transfer of commodities between nodes. E.g. electricity line,gas pipeline...",280378317271233,true], + ["node",[],"A universal aggregator of commodify flows over units and connections,with storage capabilities.",280740554077951,true], + ["unit",[],"A conversion of one/many comodities between nodes.",281470681805429,true], + ["unit__from_node",["unit","node"],"Defines the `nodes` the `unit` can take input from,and holds most `unit_flow` variable specific parameters.",281470681805657,true], + ["unit__to_node",["unit","node"],"Defines the `nodes` the `unit` can output to,and holds most `unit_flow` variable specific parameters.",281470681805658,true], + ["connection__node__node",["connection","node","node"],"Holds parameters spanning multiple `connection_flow` variables to and from multiple `nodes`.",null,true] + ], + "entities": [ + ["connection","Bastusel_to_Grytfors_disch",null], + ["node","Bastusel_lower",null], + ["node","Bastusel_upper",null], + ["node","Grytfors_upper",null], + ["unit","Bastusel_pwr_plant",null], + ["unit__from_node",["Bastusel_pwr_plant","Bastusel_upper"],null], + ["unit__to_node",["Bastusel_pwr_plant","Bastusel_lower"],null], + ["connection__node__node",["Bastusel_to_Grytfors_disch","Grytfors_upper","Bastusel_lower"],null] + ], + "parameter_value_lists": [ + ["balance_type_list","balance_type_group"], + ["balance_type_list","balance_type_node"], + ["balance_type_list","balance_type_none"] + ], + "parameter_definitions": [ + ["connection","connection_availability_factor",1,null,"Availability of the `connection`,acting as a multiplier on its `connection_capacity`. Typically between 0-1."], + ["connection__node__node","connection_flow_delay",{"type": "duration","data": "0h"},null,"Delays the `connection_flows` associated with the latter `node` in respect to the `connection_flows` associated with the first `node`."], + ["node","balance_type","balance_type_node","balance_type_list","A selector for how the `:nodal_balance` constraint should be handled."], + ["node","demand",0,null,"Demand for the `commodity` of a `node`. Energy gains can be represented using negative `demand`."], + ["node","fix_node_state",null,null,"Fixes the corresponding `node_state` variable to the provided value. Can be used for e.g. fixing boundary conditions."], + ["node","has_state",null,null,"A boolean flag for whether a `node` has a `node_state` variable."], + ["unit__from_node","unit_capacity",null,null,"Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."], + ["unit__to_node","unit_capacity",null,null,"Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."] + ], + "parameter_values": [ + ["connection__node__node",["Bastusel_to_Grytfors_disch","Grytfors_upper","Bastusel_lower"],"connection_flow_delay",{"type": "duration","data": "1h"},"Base"], + ["node","Bastusel_upper","demand",-0.2579768519,"Base"], + ["node","Bastusel_upper","fix_node_state",{"type": "time_series","data": {"2019-01-01T00:00:00": 5581.44,"2019-01-01T01:00:00": -1,"2019-01-07T23:00:00": 5417.28}},"Base"], + ["node","Bastusel_upper","has_state",null,"Base"], + ["unit__from_node",["Bastusel_pwr_plant","Bastusel_upper"],"unit_capacity",170,"Base"] + ], + "alternatives": [ + ["Base","Base alternative"] + ] + } diff --git a/docs/source/spine_db_editor/index.rst b/docs/source/spine_db_editor/index.rst index fdad1e7f2..4117698c2 100644 --- a/docs/source/spine_db_editor/index.rst +++ b/docs/source/spine_db_editor/index.rst @@ -20,4 +20,5 @@ that you can use to visualize and edit data in one or more Spine databases. removing_data managing_data importing_and_exporting_data - committing_and_rolling_back + committing_and_history + vacuum diff --git a/docs/source/spine_db_editor/managing_data.rst b/docs/source/spine_db_editor/managing_data.rst index 19e910680..97e3fcc1a 100644 --- a/docs/source/spine_db_editor/managing_data.rst +++ b/docs/source/spine_db_editor/managing_data.rst @@ -1,3 +1,8 @@ +.. |add| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_plus.svg + :width: 16 +.. |remove| image:: ../../../spinetoolbox/ui/resources/menu_icons/cube_minus.svg + :width: 16 + Managing data ------------- @@ -7,54 +12,52 @@ This section describes the available tools to manage data, i.e., adding, updatin .. contents:: :local: -Managing object groups -======================= +Managing entity groups +====================== -To modify object groups, expand the corresponding item in *Object tree* to display the **members** item, -right-click on the latter and select **Manage members** from the context menu. -The *Manage parameter tags* dialog will pop up: +To modify entity groups, expand the corresponding entity class item in **Entity Tree** to display the group item, +right-click on it and select **Manage members** from the context menu. +The *Manage members* dialog will pop up: .. image:: img/manage_members_dialog.png :align: center -To add new member objects, select them under *Non members*, and press the button in the middle that has a plus sign. -To remove current member objects, select them under *Members*, and press the button in the middle that has a minus sign. +To add new member entities, select them under *Non members*, and press the (|add|>>) button in the middle. +To remove current members, select them under *Members*, and press the (|remove| <<) button in the middle. Multiple selection works in both lists. -When you're happy, press **Ok**. - +When you're happy with the members, press **OK**. .. note:: Changes made using the *Manage members* dialog are not applied to - the database until you press **Ok**. + the database until you press **OK**. -Managing relationships -====================== +Managing N-D entities +===================== -Select **Edit -> Manage relationships** from the menu bar. -The *Manage relationships* dialog will pop up: +Right click the root item, or an N-D entity item in **Entity Tree** and from the context menu select +**Manage elements**. The *Manage elements* dialog will pop up: -.. image:: img/manage_relationships_dialog.png +.. image:: img/manage_entities_dialog.png :align: center -To get started, select a relationship class and a database from the combo boxes at the top. - -To add relationships, select the member objects for each class under *Available objects* -and press the **Add relationships** button at the middle of the form. -The relationships will appear at the top of the table under *Existing relationships*. +To get started, select an entity class and a database from the combo boxes at the top. -To add multiple relationships at the same time, -select multiple objects for one or more of the classes. +To add entities, select the elements for each class under *Available elements* +and press the add button (|add|>>) in the middle of the form. +The entities will appear at the top of the table under *Existing entities*. -.. tip:: To *extend* the selection of objects for a class, - press and hold the **Ctrl** key while clicking on more items. +To add multiple entities at the same time, +select multiple elements for one or more of the classes. All possible permutations +of the selected elements will be added to *Existing entities*. -.. note:: The set of relationships to add is determined by applying the *product* - operation over the objects selected for each class. +.. tip:: To *extend* the selection of entities for a class, + press and hold the **Ctrl** key while clicking on more items. Holding down **Shift** + allows to select an area of items by clicking the start and end of the selection. -To remove relationships, select the appropriate rows under *Existing relationships* -and press the **Remove relationships** button on the right. +To remove entities, select the appropriate rows under *Existing entities* +and press the remove button (|remove|) on the right. -When you're happy with your changes, press **Ok**. +When you're happy with your changes, press **OK**. -.. note:: Changes made using the *Manage relationships* dialog are not applied to - the database until you press **Ok**. \ No newline at end of file +.. note:: Changes made using the *Manage elements* dialog are not applied to + the database until you press **OK**. diff --git a/docs/source/spine_db_editor/removing_data.rst b/docs/source/spine_db_editor/removing_data.rst index 0c7ad6de1..c976839ea 100644 --- a/docs/source/spine_db_editor/removing_data.rst +++ b/docs/source/spine_db_editor/removing_data.rst @@ -1,4 +1,9 @@ +.. |purge| image:: ../../../spinetoolbox/ui/resources/menu_icons/bolt-lightning.svg + :width: 16 + +.. _Removing data: + Removing data ------------- @@ -7,13 +12,38 @@ This section describes the available tools to remove data. .. contents:: :local: +Purging items +============= + +To remove all items of specific types, select **Edit -> Purge...** (|purge|) from the hamburger menu. +The *Purge items* dialog will pop up: + +.. image:: img/purge_dialog.png + :align: center + +The databases that are opened in the Editor are listed under *Databases*. From there you can select +the databases where the mass removal will take place. The *Select all* -option will check all of the +boxes and *Deselect all* will in turn uncheck every box. + +The type of items that are to be deleted, need to be specified under *Items*. There are a couple of useful +buttons in addition to the same *Select all* and *Deselect all*: *Select entity and value items* and +*Select scenario items*. The former will select the *entity*, *entity_group*, *parameter_value*, +*entity_metadata* and *parameter_value_metadata* items in the list. The latter will select the *alternative*, +*scenario* and *scenario_alternative* items. When you are happy with your choices, press **Purge** to perform +the mass removal. + +.. note:: The purge dialog can also be opened from the **Properties** -dock widget of a Data Store. + +.. tip:: Purging can also be an automated part of the workflow. See :ref:`Links` for more information + about purging a database automatically. + Removing entities and classes ============================= -From *Object tree*, *Relationship tree*, or *Entity graph* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +From **Entity Tree** or **Graph View** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Select the items in *Object tree*, *Relationship tree*, or *Entity graph*, corresponding to the entities and classes +Select the items in **Entity Tree** or **Graph View**, corresponding to the entities and classes you want to remove. Then, right-click on the selection and choose **Remove** from the context menu. The *Remove items* dialog will popup: @@ -22,41 +52,44 @@ The *Remove items* dialog will popup: :align: center Specify the databases from where you want to remove each item under the *databases* column, -and press **Ok**. - -From *Pivot table* -~~~~~~~~~~~~~~~~~~ -To remove objects or relationships from a specific class, bring the class to *Pivot table* -using the **Parameter value** input type -(see :ref:`using_pivot_table_and_frozen_table`), -and select the cells in the table headers corresponding to the objects and/or relationships you want to remove. -Then, right-click on the selection and choose the corresponding **Remove** option from the context menu. - -Alternatively, to remove relationships for a specific class, -bring the class to *Pivot table* using the **Relationship** input type +and press **OK**. + +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ + +To remove entities from a specific class, bring the class to **Pivot View** +using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`), +and select the cells in the table headers corresponding to the entities you want to remove. +Then, right-click on the selection and choose the **Remove entities** option from the context menu. +This will remove the selected rows. + + +Alternatively, to remove N-D entities of a specific class, +bring the class to **Pivot View** using the **Element** input type (see :ref:`using_pivot_table_and_frozen_table`). -The *Pivot table* headers will be populated -with all possible combinations of objects across the member classes. -Locate the member objects of the relationship you want to remove, +The **Pivot View** headers will be populated +with all possible combinations of entities across the member classes. +Locate the member entities you want to remove, and uncheck the corresponding box in the table body. Removing parameter definitions and values ========================================= -From *Stacked tables* -~~~~~~~~~~~~~~~~~~~~~ +From **Table View** +~~~~~~~~~~~~~~~~~~~ To remove parameter definitions or values, -go to the relevant *Stacked table* and select any cell in the row corresponding to the items -you want to remove.s -Then, right-click on the selection and choose the appropriate **Remove** option from the context menu. +go to the relevant **Table View** and select any cell in the row corresponding to the items +you want to remove. Then, right-click on the selection and select the **Remove row(s)** +option from the context menu. Multiple selection is supported and the removal can also be +performed by pressing **Ctrl+Del**. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ To remove parameter definitions and/or values for a certain class, -bring the corresponding class to *Pivot table* using the **Parameter value** input type +bring the corresponding class to **Pivot View** using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`). Then: @@ -65,62 +98,41 @@ Then: 2. Select the cells in the table body corresponding to the parameter values you want to remove, right-click on the selection and choose **Remove parameter values** from the context menu. -Purging items -============= - -To remove all items of specific types, select **Edit -> Purge** from the hamburger menu. -The *Purge items* dialog will pop up: - -.. image:: img/mass_remove_items_dialog.png - :align: center - - -Select the databases from where you want to remove the items under *Databases*, -and the type of items you want to remove under *Items*. -Then, press **Ok**. - Removing alternatives ===================== -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). -To remove alternatives, just edit the proper cells in the **alternative** header, -right-click on the selection and choose **Remove** from the context menu. +To remove alternatives, select the to be removed items in the **alternative** header, +right-click on the selection and choose **Remove alternatives** from the context menu. -From *Alternative tree* -~~~~~~~~~~~~~~~~~~~~~~~ +From **Alternative** +~~~~~~~~~~~~~~~~~~~~ -To remove an alternative, just select the corresponding items in *Alternative tree*, +To remove an alternative, just select the corresponding items in **Alternative**, right-click on the selection and choose **Remove** from the context menu. Removing scenarios ================== -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). To remove scenarios, just select the proper cells in the **scenario** header, -right-click on the selection and choose **Remove** from the context menu. +right-click on the selection and choose **Remove scenarios** from the context menu. -From *Scenario tree* -~~~~~~~~~~~~~~~~~~~~ +From **Scenario Tree** +~~~~~~~~~~~~~~~~~~~~~~ -To remove a scenario, just select the corresponding items in *Scenario tree*, -right-click on the selection and choose **Remove** from the context menu. - -To remove a scenario alternative, select the corresponding alternative items in *Scenario tree*, -right-click on the selection and choose **Remove** from the context menu. - -Removing tools and features -=========================== - -To remove a feature, tool, or method, just select the corresponding items in *Tool/Feature tree*, +To remove a scenario, just select the corresponding items in **Scenario Tree**, right-click on the selection and choose **Remove** from the context menu. +To remove a scenario alternative from a scenario, select the corresponding alternative items +in **Scenario Tree**, right-click on the selection and choose **Remove** from the context menu. Removing parameter value lists ============================== @@ -128,16 +140,14 @@ Removing parameter value lists To remove a parameter value list or any of its values, just select the corresponding items in *Parameter value list*, right-click on the selection and choose **Remove** from the context menu. - Removing metadata ================= -Select the corresponding items in *Metadata*, right-click on the selection and choose **Remove row(s)** +Select the corresponding items in **Metadata**, right-click on the selection and choose **Remove row(s)** from the context menu. - Removing item metadata ====================== -Select the corresponding items in *Item metadata*, right-click on the selection and choose **Remove row(s)** +Select the corresponding items in **Item metadata**, right-click on the selection and choose **Remove row(s)** from the context menu. diff --git a/docs/source/spine_db_editor/spine_data_structure.rst b/docs/source/spine_db_editor/spine_data_structure.rst index 4b4009a8a..b906d1942 100644 --- a/docs/source/spine_db_editor/spine_data_structure.rst +++ b/docs/source/spine_db_editor/spine_data_structure.rst @@ -5,28 +5,5 @@ Spine data structure .. contents:: :local: -Main features --------------------- - -Spine data structure follows entity-attribute-value (EAV) with classes and relationships data model -(`Wikipedia `_). -It is an open schema where the data structure is defined through data (and not through database structure). -Spine Toolbox also adds an ability to hold alternative parameter values for the same parameter of a particular entity. -This allows the creation of scenarios. -A potential weakness of EAV is that each parameter value needs a separate row in the database which could make the parameter table large and slow. -In Spine Toolbox this is circumvented by allowing different datatypes like time series and maps to be represented in the parameter field -and thus greatly reducing the number of rows required to present large systems. - -Definitions -=========== - -1. Entity: an object (one dimension) or a relationship (n-dimensions) -2. Attribute: parameter name -3. Value: parameter value -4. Entity class: a category for entities (e.g. 'unit' is an object class while 'coal_power_plant' is an entity of 'unit' class) -5. Alternative: Each parameter value belongs to one alternative -6. Scenario: Combines alternatives into a single scenario - -Diagram -======= -.. image:: img/EAV_CR.png +Documentation for the Spine data structure can be found `here +`_. diff --git a/docs/source/spine_db_editor/updating_data.rst b/docs/source/spine_db_editor/updating_data.rst index a4a821fa7..0ebf025f1 100644 --- a/docs/source/spine_db_editor/updating_data.rst +++ b/docs/source/spine_db_editor/updating_data.rst @@ -11,72 +11,74 @@ This section describes the available tools to update existing data. Updating entities and classes ============================= -From *Object tree*, *Relationship tree*, or *Entity graph* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +From **Entity Tree** or **Graph View** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Select any number of entity and/or class items in *Object tree* -or *Relationship tree*, or any number of object and/or relationship items in *Entity graph*. +Select any number of entity and/or class items in **Entity Tree**, or any number of entity items in **Graph View**. Then, right-click on the selection and choose **Edit...** from the context menu. -One separate *Edit...* dialog will pop up for each selected entity or class type, +Depending on the selections, at least one *Edit...* dialog will pop up, and the tables will be filled with the current data of selected items. E.g.: -.. image:: img/edit_object_classes_dialog.png +.. image:: img/edit_entity_classes_dialog.png :align: center Modify the field(s) you want under the corresponding column(s). Specify the databases where you want to update each item under the *databases* column. -When you're ready, press **Ok**. +When you're ready, press **OK**. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ -To rename an object of a specific class, bring the class to *Pivot table* using any input type +To rename an entity of a specific class, bring the class to **Pivot View** using any input type (see :ref:`using_pivot_table_and_frozen_table`). Then, just edit the appropriate cell in the corresponding class header. Updating parameter definitions and values ========================================= -From *Stacked tables* +From **Table Views** ~~~~~~~~~~~~~~~~~~~~~ -To update parameter data, just go to the appropriate *Stacked table* and edit the corresponding row. +To update parameter data, just go to the appropriate **Table View** and edit the corresponding row. -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ To rename parameter definitions for a class, -bring the corresponding class to *Pivot table* using the **Parameter value** input type +bring the corresponding class to **Pivot View** using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`). Then, just edit the appropriate cell in the **parameter** header. -To modify parameter values for an object or relationship, -bring the corresponding class to *Pivot table* using the **Parameter value** input type +To modify parameter values for an entity, +bring the corresponding class to **Pivot View** using the **Value** input type (see :ref:`using_pivot_table_and_frozen_table`). Then, just edit the appropriate cell in the table body. +Updating entity alternatives +============================ + +To update an entity alternative, edit the corresponding row from **Entity Alternative** in **Table View**. Updating alternatives ===================== - -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). To rename an alternative, just edit the proper cell in the **alternative** header. -From *Alternative tree* -~~~~~~~~~~~~~~~~~~~~~~~ +From **Alternative** +~~~~~~~~~~~~~~~~~~~~ -To rename an alternative, just edit the appropriate item in *Alternative tree*. +To rename an alternative, just edit the appropriate item in **Alternative**. Updating scenarios ================== -From *Pivot table* -~~~~~~~~~~~~~~~~~~ +From **Pivot View** +~~~~~~~~~~~~~~~~~~~ Select the **Scenario** input type (see :ref:`using_pivot_table_and_frozen_table`). To rename a scenario, just edit the proper cell in the **scenario** header. @@ -85,21 +87,15 @@ To change the alternatives of a scenario as well as their ranks, check or uncheck the boxes on the pivot table. The number in the checkbox signifies the alternative's rank. -From *Scenario tree* -~~~~~~~~~~~~~~~~~~~~ +From **Scenario Tree** +~~~~~~~~~~~~~~~~~~~~~~ -To rename a scenario, just edit the appropriate item in *Scenario tree*. +To rename a scenario, just edit the appropriate item in **Scenario Tree**. To change scenario alternative ranks, just drag and drop the items under the corresponding scenario. - -Updating tools and features -=========================== - -To change a feature or method, or rename a tool, just edit the appropriate item in *Tool/Feature tree*. - - Updating parameter value lists ============================== -To rename a parameter value list or change any of its values, just edit the appropriate item in *Parameter value list*. +To rename a parameter value list or change any of its values, just edit +the appropriate item in **Parameter value list**. diff --git a/docs/source/spine_db_editor/vacuum.rst b/docs/source/spine_db_editor/vacuum.rst new file mode 100644 index 000000000..6567d57ef --- /dev/null +++ b/docs/source/spine_db_editor/vacuum.rst @@ -0,0 +1,18 @@ + +.. |broom| image:: ../../../spinetoolbox/ui/resources/menu_icons/broom.svg + :width: 16 + +.. _vacuum: + +Vacuum +====== + +Vacuuming is available for Spine Databases in the SQLite format. Basically it tries to free up some unnecessary +memory from the ``.sqlite`` -file. If you have very large databases, it might be beneficial to vacuum it once in a while. +More detailed explanation on what vacuuming does to the SQLite database can be found +`here `_. + +To vacuum a database, either press the |broom| Vacuum -button from the Data Store **Properties** -panel, or +straight from the Spine Database Editors hamburger menu **Edit->Vacuum**. + +After the vacuum is finished, a message informing the amount of bytes freed from the database is shown. diff --git a/docs/source/spine_db_editor/viewing_data.rst b/docs/source/spine_db_editor/viewing_data.rst index ad01e89d3..974f55162 100644 --- a/docs/source/spine_db_editor/viewing_data.rst +++ b/docs/source/spine_db_editor/viewing_data.rst @@ -7,182 +7,222 @@ This section describes the available tools to view data. .. contents:: :local: -Viewing entities and classes -============================ +Viewing entities and entity classes +=================================== -Using *Entity trees* -~~~~~~~~~~~~~~~~~~~~ +Using the **Entity Tree** +~~~~~~~~~~~~~~~~~~~~~~~~~ -*Entity trees* present the structure of classes and entities in all databases in the shape of a tree: +The **Entity Tree** presents the structure of entity classes and entities in all databases in the shape of a tree: -.. image:: img/object_tree.png +.. image:: img/entity_tree.png :align: center -In *Object tree*: - -- To view all object classes from all databases, +- To view all entity classes from all databases, expand the root item (automatically expanded when loading the form). -- To view all objects of a class, expand the corresponding object class item. -- To view all relationship classes involving an object class, expand any objects of that class. -- To view all relationships of a class involving a given object, - expand the corresponding relationship class item under the corresponding object item. +- To view all entities of a class, expand the corresponding entity class item. +- To view all multidimensional entities where a specific entity is an member, expand that entity. -In *Relationship tree*: +.. tip:: To *extend* the selection in **Entity Tree**, press and hold the **Ctrl** key + while clicking on the items. -- To view all relationship classes from all databases, - expand the root item (automatically expanded when loading the form). -- To view all relationships of a class, - expand the corresponding relationship class item. +Right clicking items in the **Entity Tree** will open up a context menu. Depending on what kind of item +the menu was opened from (root, entity class, entity, N-D entity) all of the options might not be available. +Unavailable options are still visible but they are greyed out: -.. note:: To expand an item in *Object tree* or *Relationship tree*, - double-click on the item or press the right arrow while it's active. - Items in gray don't have any children, thus they cannot be expanded. - To collapse an expanded item, double-click on it again or press the left arrow while it's active. +.. image:: img/entity_tree_context_menu.png + :align: center -.. tip:: To expand or collapse an item and all its descendants in *Object tree* or *Relationship tree*, - right click on the item to display the context menu, and select **Fully expand** or **Fully collapse.** +- **Copy name(s) as text** copies the data from the selection so that it can be pasted elsewhere. +- **Add entity classes** opens up a dialog to add new entity classes. +- **Add entities** opens up a dialog to add new entities. +- **Add entity group** opens up a dialog to create entity groups. +- **Manage elements** opens up a dialog where new entities can be created or existing ones deleted for + a multidimensional entity. +- **Manage members** opens up a dialog to delete or add new members in an entity group. +- **Select superclass** opens up a dialog to set the superclass for a given entity class -.. tip:: In *Object tree*, the same relationship appears in many places (as many as it has dimensions). - To jump to the next occurrence of a relationship item, either double-click on the item, - or right-click on it to display the context menu, and select **Find next**. +- **Find next occurrence** goes to the next occurrence of the N-D entity in the **Entity Tree** and selects it. + This can also be done by double-clicking the item. -Using *Entity graph* -~~~~~~~~~~~~~~~~~~~~ +- **Edit...** opens up a dialog where the name, description, icon and active by default -setting of an + entity can be changed. +- **Remove...** removes the selection. +- **Duplicate entity** duplicates the whole entity. -*Entity graph* presents the structure of classes and entities from one database in the shape of a graph: +- **Export** creates a new Spine Database in an `.sqlite` file with all of the relevant data to the selection. -.. image:: img/entity_graph.png - :align: center +- **Fully expand** expands the selection and all its children. +- **Fully collapse** collapses the selection and all its children. +- **Hide empty classes** whether to show empty classes in the tree or not. -.. tip:: To see it in action, check out `this video `_. +.. tip:: To expand an item in **Entity Tree**, you can also double-click on the item or press the right arrow. + This will only expand the next layer and leave the children expanded or collapsed depending on their previous + state. Items in gray don't have any children, thus they cannot be expanded. To collapse an expanded item, + double-click on it again or press the left arrow. + +.. tip:: **Entity Tree** also supports **Sticky selection**, which allows one to + extend the selection by clicking on items *without pressing* **Ctrl**. To enable **Sticky selection**, select + **Settings** from the hamburger menu, and check the corresponding box. + +Using the **Graph View** +~~~~~~~~~~~~~~~~~~~~~~~~ + +**Graph View** presents the structure of entities from one database in the shape of a graph: + +.. image:: img/entity_graph.png + :align: center Building the graph ****************** -To build the graph, select any number of items in either *Object tree* or *Relationship tree*. -What is included in the graph depends on the specific selection you make: - -- To include all objects and relationships from the database, - select the root item in either *Object tree* or *Relationship tree*. -- To include all objects of a class, select the corresponding class item in *Object tree*. -- To include all relationships of a class, select the corresponding class item in *Relationship tree*. -- To include all relationships of a specific class involving a specific object, - select the corresponding relationship class item under the corresponding object item in *Object tree*. -- To include specific objects or relationships, - select the corresponding item in either *Object tree* or *Relationship tree*. - -.. note:: In *Entity graph*, a small unnamed vertex represents a relationship, - whereas a bigger named vertex represents an object. An arc between a relationship and an object - indicates that the object is a member in that relationship. - -The graph automatically includes relationships whenever *all* the member objects are included -(even if these relationships are not selected in *Object tree* or *Relationship tree*). -You can change this behavior to automatically include relationships -whenever *any* of the member objects are included. -To do this, enable **Auto-expand objects** via the **Graph** menu, -or via *Entity graph*'s context menu. - -.. tip:: To *extend* the selection in *Object tree* or *Relationship tree*, press and hold the **Ctrl** key - while clicking on the items. +To build the graph, select any number of items in the **Entity Tree**. +What is included in the graph depends on the specific selection you make in the **Entity Tree**: + +- To include all entities from the database, select the root item. +- To include all entities of an entity class, select the corresponding class item. +- To include specific entities, select them by holding down **Ctrl**. -.. tip:: *Object tree* and *Relationship tree* also support **Sticky selection**, which allows one to - extend the selection by clicking on items *without pressing Ctrl*. - To enable **Sticky selection**, select **Settings** from the hamburger menu, and check the corresponding box. +.. note:: In **Graph View**, a small unnamed vertex represents a multidimensional entity with multiple elements, + whereas a bigger named vertex represents a zero dimensional entity. An arc between entities indicates that + the 0-D entity is an element of that N-D entity. + +The graph automatically includes N-D entities whenever *all* the elements of that entity are included +(even if these entities are not selected in **Entity Tree**). You can change this behavior to automatically +include N-D entities whenever *any* of the member elements are included. To do this, enable **Auto-expand entities** +via the **Graph View**'s context menu. Manipulating the graph ********************** -You can move items in the graph by dragging them with your mouse. -By default, each items moves individually. -To make relationship items move along with their member objects, -select **Settings** from the hamburger menu and check the box next to -*Move relationships along with objects in Entity graph*. - -To display *Entity graph*'s context menu, just right-click on an empty space in the graph. - -- To save the position of items into the database, - select the items in the graph and choose **Save positions** from the context menu. - To clear saved positions, select the items again and choose **Clear saved positions** from the context menu. -- To hide part of the graph, select the items you want to hide and choose **Hide** from context menu. - To show the hidden items again, select **Show hidden** from the context menu. -- To prune the graph, select the items you want to prune and then choose **Prune entities** - or **Prune classes** from the context menu. - To restore specific pruned items, display the context menu, - hover **Restore** and select the items you want to restore from the popup menu. - To restore all pruned items at once, select **Restore all** from the context menu. -- To zoom in and out, scroll your mouse wheel over *Entity graph* or use **Zoom** buttons - in the context menu. -- To rotate clockwise or anti-clockwise, press and hold the **Shift** key while scrolling your mouse wheel, - or use the **Rotate** buttons in the context menu. -- To adjust the arcs' length, use the **Arc length** buttons in the context menu. -- To rebuild the graph after moving items around, select **Rebuild graph** from the context menu. -- To export the current graph as a PDF file, select **Export graph as PDF** from the context menu. - -.. note:: *Entity graph* supports extended selection and rubber-band selection. +You can move items in the graph by dragging them with your mouse. By default, each items moves individually. +Like in the **Design view**, multiple items can be moved at once by selecting them first. + +To display **Graph View**'s context menu, just right-click on an empty space in the graph. +The context menu has the following options: + +- **Add entities...** opens up the add entities dialog, from where new entities can be added. + +- **Search** highlights the specified entities with color so that they are easier to visualize. + +- **Hide classes** can be used to disable all of the entities from an entity class from showing in the graph. + **Show** can then be used to bring back the hidden classes one by one or **Show all** to bring them all back. + +- **Prune classes** works like **Hide classes** but it also hides all the classes that have the specified class + as an element. Once again these can be brought back one by one with **Restore** or all at once with **Restore all**. + +- **Zoom** has three options: zoom out, zoom in and reset zoom. Using the scroll wheel of the mouse on the **Graph View** + also works. +- **Arc-length** has two buttons: one for making the arcs between the entities longer and one for making them shorter. +- **Rotate** rotates the whole graph by 15° per step. Also can be done by holding down **SHIFT** while scrolling with + the mouse wheel. + +- **Auto-expand entities** If enabled, the graph will also include entities where the selections are members besides + just the selections. if disabled, the graph will only show the selected entities. +- **Merge databases** Whether to merge the databases or not. +- **Snap entities to grid** makes it so that the placement of the entities can't be arbitrary anymore but + instead they can only lay on a grid. +- **Max. entity dimension count** defines a cutoff for the number of dimensions an entity can have and still be drawn. +- **Number of build iterations** defines the maximum numbers of iterations the layout generation algorithm can make. +- **Minimum distance between nodes (%)** is used for setting the ideal distance between entities in the graph. +- **Decay rate of attraction with distance** The higher this number, the lesser the attraction between distant + vertices when drawing the graph. + +- **Select graph parameters** is where different aspects of the graph can be mapped to for example parameter values. +- **Select background image** can be used to set any `.svg` image as the background for the graph. + +- **Save positions** Saves the positions of the items into the database. To clear the saved position select + **Clear saved positions**. + +- **Save state...** saves the drawn graph. Selecting a specific state from **Load state...** will load that state + into the **Graph View**. Saved states can be deleted from **Remove state**. + +- **Export as image...** can be used to export the image of the graph in either `.svg` or `.pdf` formats +- **Export as video...** can be used to export the video of the graph. + +- **Rebuild** to rebuild the whole graph. + + +.. note:: **Graph View** supports extended selection and rubber-band selection. To extend a selection, press and hold **Ctrl** while clicking on the items. - To perform rubber-band selection, press and hold **Ctrl** while dragging your mouse - around the items you want to select. + To perform rubber-band selection, drag your mouse around the items you want to select. .. note:: Pruned items are remembered across graph builds. -To display an object or relationship item's context menu, just right-click on it. +To display an entity item's context menu, just right-click on it. The context menu has a few different options: -- To expand or collapse relationships for an object item, hover **Expand** or **Collapse** and select the relationship - class from the popup menu. +- To expand or collapse N-D entities, on an entities context menu hover **Expand** or **Collapse** and select + the entity class from the popup menu. +- **Connect entities** allows the creation of new N-D entities straight from the **Graph View**. When hovering over + the option, the list of relevant multi dimensional entity classes where the selected entity could possibly be + a member are shown. After selecting one of the items in the list, the entities that you want to make up the new + new entity in the selected entity class can be selected by clicking them in the graph. Once the selections are + made, a popup showing the to be added entities is shown. By default every permutation of the selections is staged + to be added but individual items can be also deselected. +- **Edit**, **Remove** and **Duplicate** work as they do in the **Entity Tree**. -Viewing parameter definitions and values -======================================== +Viewing parameter definitions and values as well as entity alternatives +======================================================================= -Using *Stacked tables* -~~~~~~~~~~~~~~~~~~~~~~ +Using **Table Views** +~~~~~~~~~~~~~~~~~~~~~ -*Stacked tables* present object and relationship parameter data from all databases in the form of stacked tables: +**Table View**'s: *Parameter value*, *Parameter definition* and *Entity alternative* present entity data +from all databases in the form of tables: -.. image:: img/object_parameter_value_table.png +.. image:: img/entity_parameter_value_table.png :align: center -To filter *Stacked tables* by any entities and/or classes, -select the corresponding items in either *Object tree*, *Relationship tree*, or *Entity graph*. -To remove all these filters, select the root item in either *Object tree* or *Relationship tree*. +To filter a **Table View** by any entities and/or classes, +select the corresponding items in either **Entity Tree** or **Graph View**. +To remove all these filters, select the root item in **Entity Tree**. -*Stacked tables* can also be filtered by selecting alternatives or scenarios from *Alternative tree* -and *Scenario tree*. This filter is orthogonal to the entity/class filter and can be used together with it. -To remove all these filters, select the root items or deselect all items from *Alternative tree* and *Scenario tree*. +A **Table View** can also be filtered by selecting alternatives or scenarios from **Alternative** +and **Scenario tree**. This filter is orthogonal to the entity/class filter and can be used together with it. +To remove all these filters, simply select the root item in **Entity Tree** or deselect all items from +**Alternative** and **Scenario tree**. -All the filters described above can be cleared with the *Clear all filters* item available in the *Stacked tables* -right-click context menu. +All the filters described above can be cleared with the *Clear all filters* item available in the right-click +context menu of the **Table View**. -To apply a custom filter on a *Stacked table*, click on any horizontal header. +To apply a custom filter on a **Table View**, click on any horizontal header. A menu will pop up listing the items in the corresponding column: -.. image:: img/object_name_filter_menu.png +.. image:: img/entity_name_filter_menu.png :align: center Uncheck the items you don't want to see in the table and press **Ok**. Additionally, you can type in the search bar at the top of the menu to filter the list of items. To remove the current filter, select **Remove filters**. -To filter a *Stacked table* according to a selection of items in the table itself, -right-click on the selection to show the context menu, -and then select **Filter by** or **Filter excluding**. -To remove these filters, select **Remove filters** from the header menus of the filtered columns. +To filter a **Table View** according to a selection of items in the table itself, right-click on the selection +to show the context menu, and then select **Filter by** or **Filter excluding**. To remove these filters, select +**Remove filters** from the header menus of the filtered columns. -.. tip:: You can rearrange columns in *Stacked tables* by dragging the headers with your mouse. +.. tip:: You can rearrange columns in *Table Views* by dragging the headers with your mouse. The ordering will be remembered the next time you open Spine DB editor. -Viewing parameter values and relationships -========================================== +**Entity alternative** +~~~~~~~~~~~~~~~~~~~~~~ + +Entity alternative provides a way to set which entities are active and which are not in each alternative: + +.. image:: img/entity_alternative_table.png + :align: center + +Viewing parameter values and multidimensional entities +====================================================== .. _using_pivot_table_and_frozen_table: -Using *Pivot table* and *Frozen table* -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Using **Pivot View** and **Frozen Table** +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -*Pivot table* and *Frozen table* present data for an individual class from one database in the form of a pivot table, +**Pivot View** and **Frozen Table** present data for an individual class from one database in the form of a pivot table, optionally with frozen dimensions: @@ -190,29 +230,29 @@ optionally with frozen dimensions: :align: center To populate the tables with data for a certain class, -just select the corresponding class item in either *Object tree* or *Relationship tree*. +just select the corresponding class item in **Entity Tree**. Selecting the input type ************************ -*Pivot table* and *Frozen table* support four different input types: +**Pivot View** and **Frozen Table** support four different input types: -- **Parameter value** (the default): it shows objects, parameter definitions, alternatives, and databases in the headers, +- **Value** (the default): it shows entities, parameter definitions, alternatives, and databases in the headers, and corresponding parameter values in the table body. -- **Index expansion**: Similar to the above, but it also shows parameter indexes in the headers. +- **Index**: Similar to the above, but it also shows parameter indexes in the headers. Indexes are extracted from special parameter values, such as time-series. -- **Relationship**: it shows objects, and databases in the headers, and corresponding relationships in the table body. - It only works when selecting a relationship class in *Relationship tree*. -- **Scenario**: it shows scenarios, alternatives, and databases in the header, and corresponding *rank* in the table body. +- **Element**: it shows entities, and databases in the headers, and corresponding multidimensional entities + in the table body. It only works when a N-D entity is selected in the **Entity Tree**. +- **Scenario**: it shows scenarios, alternatives, and databases in the header, and corresponding *rank* + in the table body. You can select the input type from the **Pivot** section in the hamburger menu. -.. note:: In *Pivot table*, - header blocks in the top-left area indicate what is shown in each horizontal and vertical header. - For example, in **Parameter value** input type, by default, - the horizontal header has two rows, listing alternative and parameter names, respectively; - whereas the vertical header has one or more columns listing object names. +.. note:: In **Pivot View**, header blocks in the top-left area indicate what is shown in each horizontal + and vertical header. For example, in **Value** input type, by default, the horizontal header + has two rows, listing alternative and parameter names, respectively; whereas the vertical header has + one or more columns listing entity names. Pivoting and freezing @@ -222,32 +262,35 @@ To pivot the data, drag a header block across the top-left area of the table. You can turn a horizontal header into a vertical header and vice versa, as well as rearrange headers vertically or horizontally. -To freeze a dimension, drag the corresponding header block from *Pivot table* into *Frozen table*. +To freeze a dimension, drag the corresponding header block from **Pivot View** into **Frozen table**. To unfreeze a frozen dimension, just do the opposite. .. note:: Your pivoting and freezing selections for any class will be remembered when switching to another class. +.. tip:: If you are not seeing the data you think you should be seeing, it might be because there is + some selection active in the **Frozen Table** that is filtering those values out of the **Pivot View**. + Filtering ********* -To apply a custom filter on *Pivot table*, click on the arrow next to the name of any header block. +To apply a custom filter on **Pivot View**, click on the arrow next to the name of any header block. A menu will pop up listing the items in the corresponding row or column: -.. image:: img/object_name_filter_menu.png +.. image:: img/entity_name_filter_menu.png :align: center Uncheck the items you don't want to see in the table and press **Ok**. Additionally, you can type in the search bar at the top of the menu to filter the list of items. To remove the current filter, select **Remove filters**. -To filter the pivot table by an individual vector across the frozen dimensions, -select the corresponding row in *Frozen table*. +To filter the **Pivot View** by an individual vector across the frozen dimensions, +select the corresponding row in **Frozen Table**. Viewing alternatives and scenarios ================================== -You can find alternatives from all databases under *Alternative tree*: +You can find alternatives from all databases under **Alternative**: .. image:: img/alternative_tree.png :align: center @@ -258,7 +301,7 @@ expand the root item for that database. Viewing scenarios ================= -You can find scenarios from all databases under *Scenario tree*: +You can find scenarios from all databases under **Scenario tree**: .. image:: img/scenario_tree.png :align: center @@ -268,28 +311,10 @@ expand the root item for that database. To view the alternatives for a particular scenario, expand the corresponding scenario item. -Viewing tools and features -========================== - -You can find tools, features, and methods from all databases under *Tool/Feature tree*: - -.. image:: img/tool_feature_tree.png - :align: center - -To view the features and tools from each database, -expand the root item for that database. -To view all features, expand the **feature** item. -To view all tools, expand the **tool** item. -To view the features for a particular tool, expand the **tool_feature** item under the corresponding -tool item. -To view the methods for a particular tool-feature, expand the **tool_feature_method** item under the corresponding -tool-feature item. - - Viewing parameter value lists ============================= -You can find parameter value lists from all databases under *Parameter value list*: +You can find parameter value lists from all databases under **Parameter value list**: .. image:: img/parameter_value_list.png :align: center @@ -302,7 +327,7 @@ To view the values for each list, expand the corresponding list item. Viewing metadata ================ -You can find metadata from all databases under *Metadata*: +You can find metadata from all databases under **Metadata**: .. image:: img/metadata.png :align: center @@ -312,7 +337,7 @@ See also :ref:`Metadata description`. Viewing item metadata ===================== -You can find metadata for currently selected entities or parameter values under *Item metadata*: +You can find metadata for currently selected entities or parameter values under **Item metadata**: .. image:: img/item_metadata.png :align: center diff --git a/docs/source/spine_engine_server.rst b/docs/source/spine_engine_server.rst index 1a2e1a837..6f2e00ccc 100644 --- a/docs/source/spine_engine_server.rst +++ b/docs/source/spine_engine_server.rst @@ -23,9 +23,9 @@ Here is a list of items that you should be aware of when running projects on Spi - **Python Jupyter Console**. Kernel spec setting in Tool Specification Editor is ignored. Jupyter Console is launched using the **python3** kernel spec. This must be installed before the server is started. See instructions below. -- **Julia Basic Console**. Interpreter setting in app settings (Tools page in File->Settings) is ignored. Basic - Console runs the Julia that is found in PATH. See installation instructions below. -- **Julia Jupyter Console**. Kernel spec setting in app settings (Tools page in File->Settings) is ignored. Jupyter +- **Julia Basic Console**. Julia executable setting in Tool Specification Editor is ignored. Basic + Console runs the Julia that is found in the server machine's PATH. See installation instructions below. +- **Julia Jupyter Console**. Kernel spec setting in Tool Specification Editor is ignored. Jupyter Console is launched using the **julia-1.8** kernel spec. This must be installed before the server is started. See instructions below. diff --git a/docs/source/terminology.rst b/docs/source/terminology.rst index 7111f8902..de1f24ecb 100644 --- a/docs/source/terminology.rst +++ b/docs/source/terminology.rst @@ -12,7 +12,7 @@ Here is a list of definitions related to Spine project, SpineOpt.jl, and Spine T - **Arc** Graph theory term. See *Connection*. - **Case study** Spine project has 13 case studies that help to improve, validate and deploy different aspects of the SpineOpt.jl and Spine Toolbox. -- **Connection** an arrow on Spine Toolbox Design View that is used to connect project items +- **Connection** (aka **Arrow**) an arrow on Spine Toolbox Design View that is used to connect project items to each other to form a DAG. - **Data Connection** is a project item used to store a collection of data files that may or may not be in Spine data format. It facilitates data transfer from original data sources e.g. spreadsheet @@ -36,10 +36,14 @@ Here is a list of definitions related to Spine project, SpineOpt.jl, and Spine T vertices and edges. In Spine Toolbox, we use project items as vertices and connections as edges to build a DAG that represents a data processing chain (workflow). - **Edge** Graph theory term. See *Connection* -- **GdxExporter** is a project item that allows exporting a Spine data structure from a Data Store into a - .gdx file which can be used as an input file in a Tool. +- **Element** is what the entities making up a multi dimensional entity are called. See also multidimensional + entity. - **Importer** is a project item that can be used to import data from e.g. an Excel file, transform it to Spine data structure, and into a Data Store. +- **Loop** (aka **jump**) is a special sort of connection which only connects the two attached project + items if the user defined loop condition is met. +- **Multidimensional entity/entity class** (aka N-D entity/class) An entity/entity class that consists of multiple + other entities that are as it's members. Acts just like any other entity/entity class. - **Node** Graph theory term. See *Project item*. - **Predecessor** Graph theory term that is also used in Spine Toolbox. Preceding project items of a certain project item in a DAG. For example, in DAG *x->y->z*, nodes *x* and *y* are diff --git a/docs/source/tool_specification_editor.rst b/docs/source/tool_specification_editor.rst index 23df8ea33..0f462fcfa 100644 --- a/docs/source/tool_specification_editor.rst +++ b/docs/source/tool_specification_editor.rst @@ -19,6 +19,12 @@ Tool Specification Editor This section describes how to make a new Tool specification and how to edit existing Tool specifications. +.. contents:: + :local: + +General +------- + To execute a Julia, Python, GAMS, or an executable script in Spine Toolbox, you must first create a Tool specification for your project. You can open the Tool specification editor in several ways. One way is to press the arrow next to the Tool icon in the toolbar to expand the Tool specifications, @@ -131,3 +137,39 @@ context-menu. You are now ready to execute the Tool specification in Spine Toolbox. You just need to select a Tool item in the **Design view**, set the specification *Example Tool specification* for it, and click |play-all| or |play-selected| button. + +Input & Output Files in Practice +-------------------------------- + +The file names can be either hard coded or not. For example, you could have a script that requires (hard coded +in the script) a file `input.dat` and optionally works with a bunch of files that are expected to have the +`.csv` extension. In that case you would define + +- `input.dat` as an Input file +- `*.csv` as Optional input files + +The *Output files* work similarly; you can hard code the entire file name or use wildcards for *Optional output files*. + +When specifying the *Input* and *Output files* in the Specification editor, Toolbox will copy the files to the Tool's +work directory when the Tool is executed, so they are available for the script in a known location. Note, that you +can specify subdirectories for the files as well. These will be relative to the work directory. + +These options expect some level of hard-coding: file names, or at least file extensions as well as relative +locations to the work directory need to be known when writing the Tool Spec script. + +There is another, more general way to provide *Input files* to the script that does not require any kind of hard +coding: *command line arguments*. You can set them up in **Tool's Properties** tab. For example, in the project +below, a Data connection provides *Input files* for the workflow. The files are visible in the +*Available resources list* in **Tool's Properties** and they have been *dragged and dropped* into the the Tool +arguments list. + +.. image:: img/using_input_output_files_in_tool_scripts.png + :align: center + +Now, the Python script can access the files using something like:: + + import sys + file_path1 = sys.argv[1] + file_path2 = sys.argv[2] + +Of course, more serious scripts would use the `argparse` module. diff --git a/execution_tests/.gitignore b/execution_tests/.gitignore new file mode 100644 index 000000000..e587db861 --- /dev/null +++ b/execution_tests/.gitignore @@ -0,0 +1 @@ +project_package.zip diff --git a/execution_tests/active_by_default/.gitignore b/execution_tests/active_by_default/.gitignore new file mode 100644 index 000000000..cacf63d26 --- /dev/null +++ b/execution_tests/active_by_default/.gitignore @@ -0,0 +1,3 @@ +.spinetoolbox/local/ +.spinetoolbox/items/ +*.bak* diff --git a/execution_tests/active_by_default/.spinetoolbox/project.json b/execution_tests/active_by_default/.spinetoolbox/project.json new file mode 100644 index 000000000..fc67c0b82 --- /dev/null +++ b/execution_tests/active_by_default/.spinetoolbox/project.json @@ -0,0 +1,79 @@ +{ + "project": { + "version": 13, + "description": "", + "settings": { + "enable_execute_all": true + }, + "specifications": { + "Exporter": [ + { + "type": "path", + "relative": true, + "path": ".spinetoolbox/specifications/Exporter/export_entities.json" + } + ] + }, + "connections": [ + { + "name": "from Test data to Exporter", + "from": [ + "Test data", + "right" + ], + "to": [ + "Exporter", + "left" + ], + "options": { + "require_scenario_filter": true + }, + "filter_settings": { + "known_filters": { + "db_url@Test data": { + "scenario_filter": { + "base_scenario": true + } + } + }, + "auto_online": true + } + } + ], + "jumps": [] + }, + "items": { + "Test data": { + "type": "Data Store", + "description": "", + "x": -115.31149147072384, + "y": 22.059589672660213, + "url": { + "dialect": "sqlite", + "host": "", + "port": "", + "database": { + "type": "path", + "relative": true, + "path": "Test data.sqlite" + }, + "schema": "" + } + }, + "Exporter": { + "type": "Exporter", + "description": "", + "x": 17.046046565237432, + "y": 22.05958967266021, + "output_time_stamps": false, + "cancel_on_error": true, + "output_labels": [ + { + "in_label": "db_url@Test data", + "out_label": "data.csv" + } + ], + "specification": "Export entities" + } + } +} \ No newline at end of file diff --git a/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json b/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json new file mode 100644 index 000000000..2ad5fc8c5 --- /dev/null +++ b/execution_tests/active_by_default/.spinetoolbox/specifications/Exporter/export_entities.json @@ -0,0 +1,25 @@ +{ + "item_type": "Exporter", + "output_format": "csv", + "name": "Export entities", + "description": "", + "mappings": { + "Mapping (1)": { + "type": "entities", + "mapping": [ + { + "map_type": "EntityClass", + "position": 0 + }, + { + "map_type": "Entity", + "position": 1 + } + ], + "enabled": true, + "always_export_header": true, + "group_fn": "no_group", + "use_fixed_table_name": false + } + } +} \ No newline at end of file diff --git a/execution_tests/active_by_default/__init__.py b/execution_tests/active_by_default/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/execution_tests/active_by_default/execution_test.py b/execution_tests/active_by_default/execution_test.py new file mode 100644 index 000000000..f93b3adb3 --- /dev/null +++ b/execution_tests/active_by_default/execution_test.py @@ -0,0 +1,49 @@ +from pathlib import Path +import shutil +import subprocess +import sys +import unittest +from spinedb_api import DatabaseMapping + +class ActiveByDefault(unittest.TestCase): + _root_path = Path(__file__).parent + _database_path = _root_path / "Test data.sqlite" + _exporter_output_path = _root_path / ".spinetoolbox" / "items" / "exporter" / "output" + + def _check_addition(self, result): + error = result[1] + self.assertIsNone(error) + + def setUp(self): + if self._exporter_output_path.exists(): + shutil.rmtree(self._exporter_output_path) + self._database_path.parent.mkdir(parents=True, exist_ok=True) + if self._database_path.exists(): + self._database_path.unlink() + url = "sqlite:///" + str(self._database_path) + with DatabaseMapping(url, create=True) as db_map: + self._check_addition(db_map.add_entity_class_item(name="HiddenByDefault", active_by_default=False)) + self._check_addition(db_map.add_entity_item(name="hidden", entity_class_name="HiddenByDefault")) + self._check_addition(db_map.add_entity_class_item(name="VisibleByDefault", active_by_default=True)) + self._check_addition(db_map.add_entity_item(name="visible", entity_class_name="VisibleByDefault")) + self._check_addition(db_map.add_scenario_item(name="base_scenario")) + self._check_addition(db_map.add_scenario_alternative_item(scenario_name="base_scenario", alternative_name="Base", rank=0)) + db_map.commit_session("Add test data.") + + def test_execution(self): + this_file = Path(__file__) + completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) + self.assertEqual(completed.returncode, 0) + self.assertTrue(self._exporter_output_path.exists()) + self.assertEqual(len(list(self._exporter_output_path.iterdir())), 1) + output_dir = next(iter(self._exporter_output_path.iterdir())) + filter_id = (output_dir / ".filter_id").read_text(encoding="utf-8").splitlines() + self.assertEqual(len(filter_id), 1) + self.assertEqual(filter_id, ["base_scenario - Test data"]) + entities = (output_dir / "data.csv").read_text(encoding="utf-8").splitlines() + expected = ["VisibleByDefault,visible"] + self.assertCountEqual(entities, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/execution_tests/alternative_filters/.gitignore b/execution_tests/alternative_filters/.gitignore new file mode 100644 index 000000000..cacf63d26 --- /dev/null +++ b/execution_tests/alternative_filters/.gitignore @@ -0,0 +1,3 @@ +.spinetoolbox/local/ +.spinetoolbox/items/ +*.bak* diff --git a/execution_tests/alternative_filters/.spinetoolbox/project.json b/execution_tests/alternative_filters/.spinetoolbox/project.json new file mode 100644 index 000000000..0a782cfc0 --- /dev/null +++ b/execution_tests/alternative_filters/.spinetoolbox/project.json @@ -0,0 +1,83 @@ +{ + "project": { + "version": 13, + "description": "", + "settings": { + "enable_execute_all": true + }, + "specifications": { + "Exporter": [ + { + "type": "path", + "relative": true, + "path": ".spinetoolbox/specifications/Exporter/export_values.json" + } + ] + }, + "connections": [ + { + "name": "from Data Store to Exporter", + "from": [ + "Data Store", + "right" + ], + "to": [ + "Exporter", + "left" + ], + "filter_settings": { + "known_filters": { + "db_url@Data Store": { + "scenario_filter": {}, + "alternative_filter": { + "Base": true, + "alt1": false, + "alt2": true + } + } + }, + "auto_online": true, + "enabled_filter_types": { + "alternative_filter": true, + "scenario_filter": false + } + } + } + ], + "jumps": [] + }, + "items": { + "Data Store": { + "type": "Data Store", + "description": "", + "x": -251.50464049730488, + "y": -53.10655755520781, + "url": { + "dialect": "sqlite", + "host": "", + "port": "", + "database": { + "type": "path", + "relative": true, + "path": ".spinetoolbox/items/data_store/Data Store.sqlite" + }, + "schema": "" + } + }, + "Exporter": { + "type": "Exporter", + "description": "", + "x": -103.20708355068687, + "y": -53.10655755520782, + "output_time_stamps": false, + "cancel_on_error": true, + "output_labels": [ + { + "in_label": "db_url@Data Store", + "out_label": "out.dat" + } + ], + "specification": "Export values" + } + } +} \ No newline at end of file diff --git a/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json b/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json new file mode 100644 index 000000000..bff2d39b1 --- /dev/null +++ b/execution_tests/alternative_filters/.spinetoolbox/specifications/Exporter/export_values.json @@ -0,0 +1,46 @@ +{ + "item_type": "Exporter", + "output_format": "csv", + "name": "Export values", + "description": "", + "mappings": { + "Mapping (1)": { + "type": "entity_parameter_values", + "mapping": [ + { + "map_type": "EntityClass", + "position": 0 + }, + { + "map_type": "ParameterDefinition", + "position": 1 + }, + { + "map_type": "ParameterValueList", + "position": "hidden", + "ignorable": true + }, + { + "map_type": "Entity", + "position": 2 + }, + { + "map_type": "Alternative", + "position": 3 + }, + { + "map_type": "ParameterValueType", + "position": "hidden" + }, + { + "map_type": "ParameterValue", + "position": 4 + } + ], + "enabled": true, + "always_export_header": true, + "group_fn": "no_group", + "use_fixed_table_name": false + } + } +} \ No newline at end of file diff --git a/execution_tests/alternative_filters/__init__.py b/execution_tests/alternative_filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/execution_tests/alternative_filters/execution_test.py b/execution_tests/alternative_filters/execution_test.py new file mode 100644 index 000000000..f209203bd --- /dev/null +++ b/execution_tests/alternative_filters/execution_test.py @@ -0,0 +1,77 @@ +from pathlib import Path +import shutil +import subprocess +import sys +import unittest +from spinedb_api import DatabaseMapping, to_database + + +class AlternativeFilters(unittest.TestCase): + _root_path = Path(__file__).parent + _database_path = _root_path / ".spinetoolbox" / "items" / "data_store" / "Data Store.sqlite" + _exporter_output_file_path = _root_path / ".spinetoolbox" / "items" / "exporter" / "output" / "out.dat" + + def _check_addition(self, result): + error = result[1] + self.assertIsNone(error) + + def setUp(self): + if self._exporter_output_file_path.exists(): + shutil.rmtree(self._exporter_output_file_path.parent) + self._database_path.parent.mkdir(parents=True, exist_ok=True) + if self._database_path.exists(): + self._database_path.unlink() + url = "sqlite:///" + str(self._database_path) + with DatabaseMapping(url, create=True) as db_map: + self._check_addition(db_map.add_entity_class_item(name="Widget")) + self._check_addition(db_map.add_entity_item(name="gadget", entity_class_name="Widget")) + self._check_addition(db_map.add_parameter_definition_item(name="measurable", entity_class_name="Widget")) + self._check_addition(db_map.add_alternative_item(name="alt1")) + self._check_addition(db_map.add_alternative_item(name="alt2")) + value, value_type = to_database(2.0) + self._check_addition( + db_map.add_parameter_value_item( + entity_class_name="Widget", + entity_byname=("gadget",), + parameter_definition_name="measurable", + alternative_name="Base", + value=value, + type=value_type, + ) + ) + value, value_type = to_database(22.0) + self._check_addition( + db_map.add_parameter_value_item( + entity_class_name="Widget", + entity_byname=("gadget",), + parameter_definition_name="measurable", + alternative_name="alt1", + value=value, + type=value_type, + ) + ) + value, value_type = to_database(222.0) + self._check_addition( + db_map.add_parameter_value_item( + entity_class_name="Widget", + entity_byname=("gadget",), + parameter_definition_name="measurable", + alternative_name="alt2", + value=value, + type=value_type, + ) + ) + db_map.commit_session("Add test data.") + + def test_execution(self): + this_file = Path(__file__) + completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) + self.assertEqual(completed.returncode, 0) + self.assertTrue(self._exporter_output_file_path.exists()) + entities = self._exporter_output_file_path.read_text(encoding="utf-8").splitlines() + expected = ["Widget,measurable,gadget,Base,2.0", "Widget,measurable,gadget,alt2,222.0"] + self.assertCountEqual(entities, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/execution_tests/export_entities_with_entity_alternatives/.gitignore b/execution_tests/export_entities_with_entity_alternatives/.gitignore new file mode 100644 index 000000000..cacf63d26 --- /dev/null +++ b/execution_tests/export_entities_with_entity_alternatives/.gitignore @@ -0,0 +1,3 @@ +.spinetoolbox/local/ +.spinetoolbox/items/ +*.bak* diff --git a/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json new file mode 100644 index 000000000..7664d6e9d --- /dev/null +++ b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/project.json @@ -0,0 +1,78 @@ +{ + "project": { + "version": 13, + "description": "", + "settings": { + "enable_execute_all": true + }, + "specifications": { + "Exporter": [ + { + "type": "path", + "relative": true, + "path": ".spinetoolbox/specifications/Exporter/entities.json" + } + ] + }, + "connections": [ + { + "name": "from Source to Export entities", + "from": [ + "Source", + "right" + ], + "to": [ + "Export entities", + "left" + ], + "options": { + "require_scenario_filter": true + }, + "filter_settings": { + "known_filters": { + "db_url@Source": { + "scenario_filter": { + "scenario": true + } + } + }, + "auto_online": true + } + } + ], + "jumps": [] + }, + "items": { + "Source": { + "type": "Data Store", + "description": "", + "x": -130.22135416666669, + "y": 20.03405448717949, + "url": { + "dialect": "sqlite", + "host": "", + "port": "", + "database": { + "type": "path", + "relative": true, + "path": ".spinetoolbox/items/source/Source.sqlite" + } + } + }, + "Export entities": { + "type": "Exporter", + "description": "", + "x": 33.05618990384616, + "y": 20.03405448717949, + "output_time_stamps": false, + "cancel_on_error": true, + "output_labels": [ + { + "in_label": "db_url@Source", + "out_label": "entities" + } + ], + "specification": "entities" + } + } +} \ No newline at end of file diff --git a/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json new file mode 100644 index 000000000..c6a4eb43d --- /dev/null +++ b/execution_tests/export_entities_with_entity_alternatives/.spinetoolbox/specifications/Exporter/entities.json @@ -0,0 +1,25 @@ +{ + "item_type": "Exporter", + "output_format": "csv", + "name": "entities", + "description": "", + "mappings": { + "Mapping (1)": { + "type": "entities", + "mapping": [ + { + "map_type": "EntityClass", + "position": 0 + }, + { + "map_type": "Entity", + "position": 1 + } + ], + "enabled": true, + "always_export_header": true, + "group_fn": "no_group", + "use_fixed_table_name": false + } + } +} \ No newline at end of file diff --git a/execution_tests/export_entities_with_entity_alternatives/__init__.py b/execution_tests/export_entities_with_entity_alternatives/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/execution_tests/export_entities_with_entity_alternatives/execution_test.py b/execution_tests/export_entities_with_entity_alternatives/execution_test.py new file mode 100644 index 000000000..41dbdef87 --- /dev/null +++ b/execution_tests/export_entities_with_entity_alternatives/execution_test.py @@ -0,0 +1,124 @@ +import subprocess +import sys +from pathlib import Path +import shutil +import unittest + +from spinedb_api import DatabaseMapping + + +class ExportEntitiesWithEntityAlternatives(unittest.TestCase): + _root_path = Path(__file__).parent + _database_path = _root_path / ".spinetoolbox" / "items" / "source" / "Source.sqlite" + _exporter_output_path = _root_path / ".spinetoolbox" / "items" / "export_entities" / "output" + + def setUp(self): + if self._exporter_output_path.exists(): + shutil.rmtree(self._exporter_output_path) + self._database_path.parent.mkdir(parents=True, exist_ok=True) + if self._database_path.exists(): + self._database_path.unlink() + url = "sqlite:///" + str(self._database_path) + with DatabaseMapping(url, create=True) as db_map: + self._assert_item_added(db_map.add_entity_class_item(name="Object", active_by_default=True)) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_none")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_none")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_true")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_true")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_true")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="true_false")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_false")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="none_false")) + self._assert_item_added(db_map.add_entity_item(entity_class_name="Object", name="false_none")) + self._assert_item_added(db_map.add_scenario_item(name="scenario")) + self._assert_item_added(db_map.add_alternative_item(name="first")) + self._assert_item_added(db_map.add_alternative_item(name="second")) + self._assert_item_added( + db_map.add_scenario_alternative_item(scenario_name="scenario", alternative_name="first", rank=0) + ) + self._assert_item_added( + db_map.add_scenario_alternative_item(scenario_name="scenario", alternative_name="second", rank=1) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("true_none",), alternative_name="first", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("none_true",), alternative_name="second", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("true_true",), alternative_name="first", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("true_true",), alternative_name="second", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("false_true",), alternative_name="first", active=False + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("false_true",), alternative_name="second", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("true_false",), alternative_name="first", active=True + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("true_false",), alternative_name="second", active=False + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("false_false",), alternative_name="first", active=False + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("false_false",), alternative_name="second", active=False + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("none_false",), alternative_name="second", active=False + ) + ) + self._assert_item_added( + db_map.add_entity_alternative_item( + entity_class_name="Object", entity_byname=("false_none",), alternative_name="first", active=False + ) + ) + db_map.commit_session("Add test data.") + + def _assert_item_added(self, result): + self.assertIsNone(result[1]) + self.assertIsNotNone(result[0]) + + def test_execution(self): + this_file = Path(__file__) + completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) + self.assertEqual(completed.returncode, 0) + self.assertTrue(self._exporter_output_path.exists()) + self.assertEqual(len(list(self._exporter_output_path.iterdir())), 1) + output_dir = next(iter(self._exporter_output_path.iterdir())) + filter_id = (output_dir / ".filter_id").read_text(encoding="utf-8").splitlines() + self.assertEqual(len(filter_id), 1) + self.assertEqual(filter_id, ["scenario - Source"]) + entities = (output_dir / "entities.csv").read_text(encoding="utf-8").splitlines() + expected = ["Object,none_none", "Object,none_true", "Object,true_none", "Object,true_true", "Object,false_true"] + self.assertCountEqual(entities, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/execution_tests/hello_world_on_server/.gitignore b/execution_tests/hello_world_on_server/.gitignore new file mode 100644 index 000000000..7dd55e79e --- /dev/null +++ b/execution_tests/hello_world_on_server/.gitignore @@ -0,0 +1,3 @@ +.spinetoolbox/local/ +.spinetoolbox/items/simple_tool/output/ +*.bak* diff --git a/execution_tests/hello_world_on_server/.spinetoolbox/project.json b/execution_tests/hello_world_on_server/.spinetoolbox/project.json new file mode 100644 index 000000000..a0ad736cd --- /dev/null +++ b/execution_tests/hello_world_on_server/.spinetoolbox/project.json @@ -0,0 +1,59 @@ +{ + "project": { + "version": 13, + "description": "", + "settings": { + "enable_execute_all": true + }, + "specifications": { + "Tool": [ + { + "type": "path", + "relative": true, + "path": ".spinetoolbox/specifications/Tool/simple_tool_spec.json" + } + ] + }, + "connections": [ + { + "name": "from Data Connection 1 to Simple Tool", + "from": [ + "Data Connection 1", + "right" + ], + "to": [ + "Simple Tool", + "left" + ] + } + ], + "jumps": [] + }, + "items": { + "Data Connection 1": { + "type": "Data Connection", + "description": "", + "x": -184.36899456147438, + "y": -16.491179330385762, + "file_references": [ + { + "type": "path", + "relative": true, + "path": "input_file.txt" + } + ], + "db_references": [] + }, + "Simple Tool": { + "type": "Tool", + "description": "", + "x": -28.398563508064505, + "y": 31.54655297939068, + "specification": "Simple Tool Spec", + "execute_in_work": true, + "cmd_line_args": [], + "kill_completed_processes": false, + "log_process_output": false + } + } +} \ No newline at end of file diff --git a/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json b/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json new file mode 100644 index 000000000..75f0485ab --- /dev/null +++ b/execution_tests/hello_world_on_server/.spinetoolbox/specifications/Tool/simple_tool_spec.json @@ -0,0 +1,17 @@ +{ + "name": "Simple Tool Spec", + "tooltype": "python", + "includes": [ + "simple_script.py" + ], + "description": "", + "inputfiles": [ + "input_file.txt" + ], + "inputfiles_opt": [], + "outputfiles": [ + "output_file.txt" + ], + "cmdline_args": [], + "includes_main_path": "../../.." +} \ No newline at end of file diff --git a/execution_tests/hello_world_on_server/__init__.py b/execution_tests/hello_world_on_server/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret b/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret new file mode 100644 index 000000000..c2609edda --- /dev/null +++ b/execution_tests/hello_world_on_server/client_secfolder/private_keys/client.key_secret @@ -0,0 +1,8 @@ +# **** Generated on 2021-09-16 08:25:54.752807 by pyzmq **** +# ZeroMQ CURVE **Secret** Certificate +# DO NOT PROVIDE THIS FILE TO OTHER USERS nor change its permissions. + +metadata +curve + public-key = "J53)B+s.XJ7sVu3S1$o6FcYCp*= 30:\n exit(1)\nout_url = sys.argv[2]\nout_db_map = DatabaseMapping(out_url)\nimport_object_parameter_values(out_db_map, ((\"Counter\", \"loop_counter\", \"count\", counter),))\nout_db_map.commit_session(\"Increment counter.\")\nout_db_map.connection.close()\nexit(0)" + "script": "import sys\nfrom spinedb_api import DatabaseMapping, from_database, import_parameter_values\nin_url = sys.argv[1]\nwith DatabaseMapping(in_url) as in_db_map:\n\tsq = in_db_map.entity_parameter_value_sq\n\tday_count_row = in_db_map.query(sq).filter(sq.c.entity_class_name==\"Timeline\", sq.c.entity_name==\"days_of_our_lives\", sq.c.parameter_name==\"cumulative_count\").first()\nday_count = from_database(day_count_row.value, day_count_row.type)\ncounter = day_count.values[-1]\nif counter >= 30:\n exit(1)\nout_url = sys.argv[2]\nwith DatabaseMapping(out_url) as out_db_map:\n\timport_parameter_values(out_db_map, ((\"Counter\", \"loop_counter\", \"count\", counter),))\n\tout_db_map.commit_session(\"Increment counter.\")\nexit(0)\n" }, "cmd_line_args": [ { @@ -106,10 +109,7 @@ } ] } - ], - "settings": { - "enable_execute_all": true - } + ] }, "items": { "Write data": { @@ -124,7 +124,9 @@ "type": "resource", "arg": "db_url@Loop counter store" } - ] + ], + "kill_completed_processes": false, + "log_process_output": false }, "Import": { "type": "Importer", @@ -133,7 +135,6 @@ "y": -4.007282712511945, "specification": "Import data", "cancel_on_error": false, - "purge_before_writing": false, "on_conflict": "merge", "file_selection": [ [ @@ -181,7 +182,6 @@ "y": -4.007282712511941, "specification": "Counter data importer", "cancel_on_error": false, - "purge_before_writing": false, "on_conflict": "merge", "file_selection": [ [ diff --git a/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json b/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json index 4892395ab..0fe2825dc 100644 --- a/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json +++ b/execution_tests/loop_condition_with_cmd_line_args/.spinetoolbox/specifications/Tool/data_writer.json @@ -11,6 +11,5 @@ "data.csv" ], "cmdline_args": [], - "execute_in_work": true, "includes_main_path": "../../.." } \ No newline at end of file diff --git a/execution_tests/loop_condition_with_cmd_line_args/execution_test.py b/execution_tests/loop_condition_with_cmd_line_args/execution_test.py index 9583e6812..d13820658 100644 --- a/execution_tests/loop_condition_with_cmd_line_args/execution_test.py +++ b/execution_tests/loop_condition_with_cmd_line_args/execution_test.py @@ -3,7 +3,7 @@ import subprocess import sys import unittest -from spinedb_api import DatabaseMapping, from_database, Map +from spinedb_api import create_new_spine_database, DatabaseMapping, from_database, Map class LoopConditionWithCmdLineArgs(unittest.TestCase): @@ -24,23 +24,20 @@ def setUp(self): database_path.parent.mkdir(parents=True, exist_ok=True) if database_path.exists(): database_path.unlink() - db_map = DatabaseMapping(url, create=True) - db_map.connection.close() + create_new_spine_database(url) def test_execution(self): completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(self._root_path))) self.assertEqual(completed.returncode, 0) - db_map = DatabaseMapping(self._loop_counter_database_url) - value_rows = db_map.query(db_map.object_parameter_value_sq).all() - self.assertEqual(len(value_rows), 1) - loop_counter = from_database(value_rows[0].value, value_rows[0].type) - db_map.connection.close() + with DatabaseMapping(self._loop_counter_database_url) as db_map: + value_rows = db_map.query(db_map.parameter_value_sq).all() + self.assertEqual(len(value_rows), 1) + loop_counter = from_database(value_rows[0].value, value_rows[0].type) self.assertEqual(loop_counter, 20.0) - db_map = DatabaseMapping(self._output_database_url) - value_rows = db_map.query(db_map.object_parameter_value_sq).all() - self.assertEqual(len(value_rows), 1) - output_value = from_database(value_rows[0].value, value_rows[0].type) - db_map.connection.close() + with DatabaseMapping(self._output_database_url) as db_map: + value_rows = db_map.query(db_map.parameter_value_sq).all() + self.assertEqual(len(value_rows), 1) + output_value = from_database(value_rows[0].value, value_rows[0].type) expected_x = [f"T{i:03}" for i in range(31)] expected_y = [float(i) for i in range(31)] self.assertEqual(output_value, Map(expected_x, expected_y)) diff --git a/execution_tests/loop_condition_with_cmd_line_args/tool.py b/execution_tests/loop_condition_with_cmd_line_args/tool.py index ef6eafc39..cb70bca3a 100644 --- a/execution_tests/loop_condition_with_cmd_line_args/tool.py +++ b/execution_tests/loop_condition_with_cmd_line_args/tool.py @@ -3,13 +3,14 @@ from spinedb_api import DatabaseMapping, from_database url = sys.argv[1] -db_map = DatabaseMapping(url) -sq = db_map.object_parameter_value_sq -count_row = db_map.query(sq).filter( - sq.c.object_class_name == "Counter", sq.c.object_name == "loop_counter", sq.c.parameter_name == "count" -).first() +with DatabaseMapping(url) as db_map: + sq = db_map.entity_parameter_value_sq + count_row = ( + db_map.query(sq) + .filter(sq.c.entity_class_name == "Counter", sq.c.entity_name == "loop_counter", sq.c.parameter_name == "count") + .first() + ) count = int(from_database(count_row.value, count_row.type)) -db_map.connection.close() data = [[f"T{i:03}", i] for i in range(count, count + 11)] with open("data.csv", "w", newline="") as data_file: diff --git a/execution_tests/merger_write_order/.spinetoolbox/project.json b/execution_tests/merger_write_order/.spinetoolbox/project.json index c33bab31c..15d632063 100644 --- a/execution_tests/merger_write_order/.spinetoolbox/project.json +++ b/execution_tests/merger_write_order/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "", "specifications": {}, "connections": [ diff --git a/execution_tests/merger_write_order/execution_test.py b/execution_tests/merger_write_order/execution_test.py index 6fd7ef267..3ec7c2a4d 100644 --- a/execution_tests/merger_write_order/execution_test.py +++ b/execution_tests/merger_write_order/execution_test.py @@ -3,7 +3,7 @@ import sys import unittest -from spinedb_api import DatabaseMapping, from_database, import_functions +from spinedb_api import create_new_spine_database, DatabaseMapping, from_database, import_functions class MergerWriteOrder(unittest.TestCase): @@ -18,36 +18,30 @@ def setUp(self): database_path.parent.mkdir(parents=True, exist_ok=True) if database_path.exists(): database_path.unlink() - spoon_volumes = { - self._source_database_1_path: 1.0, - self._source_database_2_path: 99.0 - } + spoon_volumes = {self._source_database_1_path: 1.0, self._source_database_2_path: 99.0} for database_path, spoon_volume in spoon_volumes.items(): url = "sqlite:///" + str(database_path) - db_map = DatabaseMapping(url, create=True) - import_functions.import_object_classes(db_map, ("Widget",)) - import_functions.import_objects(db_map, (("Widget", "spoon"),)) - import_functions.import_object_parameters(db_map, (("Widget", "volume"),)) - import_functions.import_object_parameter_values(db_map, (("Widget", "spoon", "volume", spoon_volume, "Base"),)) - db_map.commit_session("Add test data.") - db_map.connection.close() + with DatabaseMapping(url, create=True) as db_map: + import_functions.import_entity_classes(db_map, ("Widget",)) + import_functions.import_entities(db_map, (("Widget", "spoon"),)) + import_functions.import_parameter_definitions(db_map, (("Widget", "volume"),)) + import_functions.import_parameter_values(db_map, (("Widget", "spoon", "volume", spoon_volume, "Base"),)) + db_map.commit_session("Add test data.") self._sink_url = "sqlite:///" + str(self._sink_database_path) - db_map = DatabaseMapping(self._sink_url, create=True) - db_map.connection.close() + create_new_spine_database(self._sink_url) def test_execution(self): this_file = Path(__file__) completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) self.assertEqual(completed.returncode, 0) - db_map = DatabaseMapping(self._sink_url) - value_rows = db_map.query(db_map.object_parameter_value_sq).all() - self.assertEqual(len(value_rows), 1) - self.assertEqual(value_rows[0].object_class_name, "Widget") - self.assertEqual(value_rows[0].object_name, "spoon") - self.assertEqual(value_rows[0].parameter_name, "volume") - self.assertEqual(value_rows[0].alternative_name, "Base") - self.assertEqual(from_database(value_rows[0].value, value_rows[0].type), 99.0) - db_map.connection.close() + with DatabaseMapping(self._sink_url) as db_map: + value_rows = db_map.query(db_map.entity_parameter_value_sq).all() + self.assertEqual(len(value_rows), 1) + self.assertEqual(value_rows[0].entity_class_name, "Widget") + self.assertEqual(value_rows[0].entity_name, "spoon") + self.assertEqual(value_rows[0].parameter_name, "volume") + self.assertEqual(value_rows[0].alternative_name, "Base") + self.assertEqual(from_database(value_rows[0].value, value_rows[0].type), 99.0) if __name__ == '__main__': diff --git a/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json b/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json index 456025258..3d541bee9 100644 --- a/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json +++ b/execution_tests/modify_connection_filter_by_script/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "", "specifications": { "Exporter": [ diff --git a/execution_tests/modify_connection_filter_by_script/execution_test.py b/execution_tests/modify_connection_filter_by_script/execution_test.py index f30f38028..b4661a268 100644 --- a/execution_tests/modify_connection_filter_by_script/execution_test.py +++ b/execution_tests/modify_connection_filter_by_script/execution_test.py @@ -5,14 +5,15 @@ import subprocess import unittest from spinedb_api import ( - DiffDatabaseMapping, + DatabaseMapping, import_alternatives, + import_entities, + import_entity_alternatives, + import_entity_classes, + import_parameter_definitions, + import_parameter_values, import_scenario_alternatives, import_scenarios, - import_object_classes, - import_objects, - import_object_parameters, - import_object_parameter_values, ) @@ -29,22 +30,22 @@ def setUp(self): if self._database_path.exists(): self._database_path.unlink() url = "sqlite:///" + str(self._database_path) - db_map = DiffDatabaseMapping(url, create=True) - import_object_classes(db_map, ("object_class",)) - import_objects(db_map, (("object_class", "object"),)) - import_object_parameters(db_map, (("object_class", "parameter"),)) - import_alternatives(db_map, ("alternative",)) - import_object_parameter_values( - db_map, - ( - ("object_class", "object", "parameter", 1.0, "Base"), - ("object_class", "object", "parameter", 2.0, "alternative"), - ), - ) - import_scenarios(db_map, (("scenario", True),)) - import_scenario_alternatives(db_map, (("scenario", "alternative"),)) - db_map.commit_session("Add test data.") - db_map.connection.close() + with DatabaseMapping(url, create=True) as db_map: + import_entity_classes(db_map, (("object_class",),)) + import_entities(db_map, (("object_class", "object"),)) + import_parameter_definitions(db_map, (("object_class", "parameter"),)) + import_alternatives(db_map, ("alternative",)) + import_entity_alternatives(db_map, (("object_class", "object", "alternative", True),)) + import_parameter_values( + db_map, + ( + ("object_class", "object", "parameter", 1.0, "Base"), + ("object_class", "object", "parameter", 2.0, "alternative"), + ), + ) + import_scenarios(db_map, (("scenario", True),)) + import_scenario_alternatives(db_map, (("scenario", "alternative"),)) + db_map.commit_session("Add test data.") def test_execution(self): completed = subprocess.run( diff --git a/execution_tests/modify_connection_filter_by_script/mod.py b/execution_tests/modify_connection_filter_by_script/mod.py index e154eccc3..7ddf6bfc5 100644 --- a/execution_tests/modify_connection_filter_by_script/mod.py +++ b/execution_tests/modify_connection_filter_by_script/mod.py @@ -3,10 +3,7 @@ db_path = project.project_dir / ".spinetoolbox" / "items" / "data" / "Data.sqlite" db_url = "sqlite:///" + str(db_path) -db_map = DatabaseMapping(db_url) -try: +with DatabaseMapping(db_url) as db_map: scenario_ids = {r.name: r.id for r in db_map.query(db_map.scenario_sq).all()} connection = project.find_connection("Data", "Export values") connection.set_filter_enabled("db_url@Data", SCENARIO_FILTER_TYPE, "scenario", True) -finally: - db_map.connection.close() diff --git a/execution_tests/parallel_importer/.spinetoolbox/project.json b/execution_tests/parallel_importer/.spinetoolbox/project.json index f9dd0bc3e..9c73adb1b 100644 --- a/execution_tests/parallel_importer/.spinetoolbox/project.json +++ b/execution_tests/parallel_importer/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "", "specifications": { "Tool": [ diff --git a/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json b/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json index 2ad2338db..e5bb667c5 100644 --- a/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json +++ b/execution_tests/parallel_importer/.spinetoolbox/specifications/Tool/test_tool.json @@ -11,6 +11,5 @@ "out.csv" ], "cmdline_args": [], - "execute_in_work": true, "includes_main_path": "../../.." } \ No newline at end of file diff --git a/execution_tests/parallel_importer/execution_test.py b/execution_tests/parallel_importer/execution_test.py index 0b2764d9a..cae49d27b 100644 --- a/execution_tests/parallel_importer/execution_test.py +++ b/execution_tests/parallel_importer/execution_test.py @@ -4,14 +4,15 @@ import subprocess import unittest from spinedb_api import ( + create_new_spine_database, DatabaseMapping, from_database, import_alternatives, + import_entities, + import_entity_classes, + import_parameter_definitions, + import_parameter_values, import_scenario_alternatives, - import_object_classes, - import_object_parameters, - import_object_parameter_values, - import_objects, import_scenarios, ) @@ -29,42 +30,44 @@ def setUp(self): if self._source_database_path.exists(): self._source_database_path.unlink() url = "sqlite:///" + str(self._source_database_path) - db_map = DatabaseMapping(url, create=True) - import_alternatives(db_map, ("alternative_1", "alternative_2")) - import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) - import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) - import_object_classes(db_map, ("content",)) - import_objects(db_map, (("content", "test_data"),)) - import_object_parameters(db_map, (("content", "only_value"),)) - import_object_parameter_values(db_map, (("content", "test_data", "only_value", 11.0, "alternative_1"),)) - import_object_parameter_values(db_map, (("content", "test_data", "only_value", 22.0, "alternative_2"),)) - db_map.commit_session("Add test data.") - db_map.connection.close() + with DatabaseMapping(url, create=True) as db_map: + import_alternatives(db_map, ("alternative_1", "alternative_2")) + import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) + import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) + import_entity_classes(db_map, ("content",)) + import_entities(db_map, (("content", "test_data"),)) + import_parameter_definitions(db_map, (("content", "only_value"),)) + import_parameter_values( + db_map, + ( + ("content", "test_data", "only_value", 11.0, "alternative_1"), + ("content", "test_data", "only_value", 22.0, "alternative_2"), + ), + ) + db_map.commit_session("Add test data.") self._sink_database_path.parent.mkdir(parents=True, exist_ok=True) if self._sink_database_path.exists(): self._sink_database_path.unlink() self._sink_url = "sqlite:///" + str(self._sink_database_path) - db_map = DatabaseMapping(self._sink_url, create=True) - db_map.connection.close() + create_new_spine_database(self._sink_url) def test_execution(self): this_file = Path(__file__) completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) self.assertEqual(completed.returncode, 0) - db_map = DatabaseMapping(self._sink_url) - value_rows = db_map.query(db_map.object_parameter_value_sq).all() + with DatabaseMapping(self._sink_url) as db_map: + value_rows = db_map.query(db_map.entity_parameter_value_sq).all() self.assertEqual(len(value_rows), 2) expected_common_data = { - "object_class_name": "result", - "object_name": "test_data", + "entity_class_name": "result", + "entity_name": "test_data", "parameter_name": "final_value", } scenario_1_checked = False scenario_2_checked = False for value_row in value_rows: - row_as_dict = value_row._asdict() for key, expected_value in expected_common_data.items(): - self.assertEqual(row_as_dict[key], expected_value) + self.assertEqual(value_row[key], expected_value) value = from_database(value_row.value, value_row.type) if value == 11.0: self.assertTrue(value_row.alternative_name.startswith("scenario_1__Import@")) @@ -74,7 +77,6 @@ def test_execution(self): scenario_2_checked = True self.assertTrue(scenario_1_checked) self.assertTrue(scenario_2_checked) - db_map.connection.close() if __name__ == '__main__': diff --git a/execution_tests/parallel_importer/tool.py b/execution_tests/parallel_importer/tool.py index a26bf3e03..26f8f4f89 100644 --- a/execution_tests/parallel_importer/tool.py +++ b/execution_tests/parallel_importer/tool.py @@ -3,12 +3,9 @@ from spinedb_api import DatabaseMapping, from_database url = sys.argv[1] -db_map = DatabaseMapping(url) -try: - value_row = db_map.query(db_map.object_parameter_value_sq).first() - value = from_database(value_row.value, value_row.type) -finally: - db_map.connection.close() +with DatabaseMapping(url) as db_map: + value_row = db_map.query(db_map.parameter_value_sq).first() +value = from_database(value_row.value, value_row.type) with open("out.csv", "w") as out_file: out_writer = csv.writer(out_file) out_writer.writerow([value]) diff --git a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json index 9c40af724..80aea08c7 100644 --- a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json +++ b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "", "specifications": { "Tool": [ diff --git a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json index 8c5d2dd0a..2a920cdea 100644 --- a/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json +++ b/execution_tests/parallel_importer_with_datapackage/.spinetoolbox/specifications/Tool/test_tool.json @@ -11,6 +11,5 @@ "*.csv" ], "cmdline_args": [], - "execute_in_work": true, "includes_main_path": "../../.." } \ No newline at end of file diff --git a/execution_tests/parallel_importer_with_datapackage/execution_test.py b/execution_tests/parallel_importer_with_datapackage/execution_test.py index d96d2bc19..56578ca09 100644 --- a/execution_tests/parallel_importer_with_datapackage/execution_test.py +++ b/execution_tests/parallel_importer_with_datapackage/execution_test.py @@ -4,14 +4,15 @@ import subprocess import unittest from spinedb_api import ( + create_new_spine_database, DatabaseMapping, from_database, import_alternatives, + import_entities, + import_entity_classes, + import_parameter_definitions, + import_parameter_values, import_scenario_alternatives, - import_object_classes, - import_object_parameters, - import_object_parameter_values, - import_objects, import_scenarios, ) @@ -29,42 +30,44 @@ def setUp(self): if self._source_database_path.exists(): self._source_database_path.unlink() url = "sqlite:///" + str(self._source_database_path) - db_map = DatabaseMapping(url, create=True) - import_alternatives(db_map, ("alternative_1", "alternative_2")) - import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) - import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) - import_object_classes(db_map, ("content",)) - import_objects(db_map, (("content", "test_data"),)) - import_object_parameters(db_map, (("content", "only_value"),)) - import_object_parameter_values(db_map, (("content", "test_data", "only_value", 11.0, "alternative_1"),)) - import_object_parameter_values(db_map, (("content", "test_data", "only_value", 22.0, "alternative_2"),)) - db_map.commit_session("Add test data.") - db_map.connection.close() + with DatabaseMapping(url, create=True) as db_map: + import_alternatives(db_map, ("alternative_1", "alternative_2")) + import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) + import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) + import_entity_classes(db_map, ("content",)) + import_entities(db_map, (("content", "test_data"),)) + import_parameter_definitions(db_map, (("content", "only_value"),)) + import_parameter_values( + db_map, + ( + ("content", "test_data", "only_value", 11.0, "alternative_1"), + ("content", "test_data", "only_value", 22.0, "alternative_2"), + ), + ) + db_map.commit_session("Add test data.") self._sink_database_path.parent.mkdir(parents=True, exist_ok=True) if self._sink_database_path.exists(): self._sink_database_path.unlink() self._sink_url = "sqlite:///" + str(self._sink_database_path) - db_map = DatabaseMapping(self._sink_url, create=True) - db_map.connection.close() + create_new_spine_database(self._sink_url) def test_execution(self): this_file = Path(__file__) completed = subprocess.run((sys.executable, "-m", "spinetoolbox", "--execute-only", str(this_file.parent))) self.assertEqual(completed.returncode, 0) - db_map = DatabaseMapping(self._sink_url) - value_rows = db_map.query(db_map.object_parameter_value_sq).all() + with DatabaseMapping(self._sink_url) as db_map: + value_rows = db_map.query(db_map.entity_parameter_value_sq).all() self.assertEqual(len(value_rows), 2) expected_common_data = { - "object_class_name": "result", - "object_name": "test_data", + "entity_class_name": "result", + "entity_name": "test_data", "parameter_name": "final_value", } scenario_1_checked = False scenario_2_checked = False for value_row in value_rows: - row_as_dict = value_row._asdict() for key, expected_value in expected_common_data.items(): - self.assertEqual(row_as_dict[key], expected_value) + self.assertEqual(value_row[key], expected_value) value = from_database(value_row.value, value_row.type) if value == 11.0: self.assertTrue(value_row.alternative_name.startswith("scenario_1__Import@")) @@ -74,7 +77,6 @@ def test_execution(self): scenario_2_checked = True self.assertTrue(scenario_1_checked) self.assertTrue(scenario_2_checked) - db_map.connection.close() if __name__ == '__main__': diff --git a/execution_tests/parallel_importer_with_datapackage/tool.py b/execution_tests/parallel_importer_with_datapackage/tool.py index 2c1d7dd1c..5a8e6ee8b 100644 --- a/execution_tests/parallel_importer_with_datapackage/tool.py +++ b/execution_tests/parallel_importer_with_datapackage/tool.py @@ -3,12 +3,9 @@ from spinedb_api import DatabaseMapping, from_database url = sys.argv[1] -db_map = DatabaseMapping(url) -try: - value_row = db_map.query(db_map.object_parameter_value_sq).first() +with DatabaseMapping(url) as db_map: + value_row = db_map.query(db_map.parameter_value_sq).first() value = from_database(value_row.value, value_row.type) -finally: - db_map.connection.close() with open("out.csv", "w", newline="") as out_file: out_writer = csv.writer(out_file) out_writer.writerow(["final_value"]) diff --git a/execution_tests/scenario_filters/.spinetoolbox/project.json b/execution_tests/scenario_filters/.spinetoolbox/project.json index 3b24211ba..9c80d17a0 100644 --- a/execution_tests/scenario_filters/.spinetoolbox/project.json +++ b/execution_tests/scenario_filters/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "Test project to test scenario filtering in a Tool project item.", "specifications": { "Importer": [ diff --git a/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json b/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json index 28061a4ba..e87266c80 100644 --- a/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json +++ b/execution_tests/scenario_filters/.spinetoolbox/specifications/Tool/write_values.json @@ -11,7 +11,5 @@ "out.dat" ], "cmdline_args": [], - "execute_in_work": true, - "includes_main_path": "../../..", - "execution_settings": {} + "includes_main_path": "../../.." } \ No newline at end of file diff --git a/execution_tests/scenario_filters/execution_test.py b/execution_tests/scenario_filters/execution_test.py index 300b36eb8..b3859ef20 100644 --- a/execution_tests/scenario_filters/execution_test.py +++ b/execution_tests/scenario_filters/execution_test.py @@ -3,7 +3,7 @@ import shutil import subprocess import unittest -from spinedb_api import DiffDatabaseMapping, import_alternatives, import_scenario_alternatives, import_scenarios +from spinedb_api import DatabaseMapping, import_alternatives, import_scenario_alternatives, import_scenarios class ScenarioFilters(unittest.TestCase): @@ -18,12 +18,11 @@ def setUp(self): if self._database_path.exists(): self._database_path.unlink() url = "sqlite:///" + str(self._database_path) - db_map = DiffDatabaseMapping(url, create=True) - import_alternatives(db_map, ("alternative_1", "alternative_2")) - import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) - import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) - db_map.commit_session("Add test data.") - db_map.connection.close() + with DatabaseMapping(url, create=True) as db_map: + import_alternatives(db_map, ("alternative_1", "alternative_2")) + import_scenarios(db_map, (("scenario_1", True), ("scenario_2", True))) + import_scenario_alternatives(db_map, (("scenario_1", "alternative_1"), ("scenario_2", "alternative_2"))) + db_map.commit_session("Add test data.") def test_execution(self): this_file = Path(__file__) diff --git a/execution_tests/scenario_filters/tool.py b/execution_tests/scenario_filters/tool.py index 1104c615b..5fdc34dab 100644 --- a/execution_tests/scenario_filters/tool.py +++ b/execution_tests/scenario_filters/tool.py @@ -2,8 +2,7 @@ from spinedb_api import DatabaseMapping, from_database url = sys.argv[1] -db_map = DatabaseMapping(url) -parameter_value = from_database(db_map.query(db_map.parameter_value_sq).first().value) +with DatabaseMapping(url) as db_map: + parameter_value = from_database(db_map.query(db_map.parameter_value_sq).first().value) with open("out.dat", "w") as out_file: out_file.write(f"{parameter_value}") -db_map.connection.close() diff --git a/execution_tests/simple_importer_on_server/.gitignore b/execution_tests/simple_importer_on_server/.gitignore new file mode 100644 index 000000000..72f8f3a48 --- /dev/null +++ b/execution_tests/simple_importer_on_server/.gitignore @@ -0,0 +1,2 @@ +.spinetoolbox/local/ +*.bak* diff --git a/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx b/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx new file mode 100644 index 000000000..10f8a3925 Binary files /dev/null and b/execution_tests/simple_importer_on_server/.spinetoolbox/items/raw_data/units.xlsx differ diff --git a/execution_tests/simple_importer_on_server/.spinetoolbox/project.json b/execution_tests/simple_importer_on_server/.spinetoolbox/project.json new file mode 100644 index 000000000..e87dfd0d0 --- /dev/null +++ b/execution_tests/simple_importer_on_server/.spinetoolbox/project.json @@ -0,0 +1,104 @@ +{ + "project": { + "version": 13, + "description": "", + "settings": { + "enable_execute_all": true + }, + "specifications": { + "Importer": [ + { + "type": "path", + "relative": true, + "path": "Importer 1 - units.xlsx.json" + } + ] + }, + "connections": [ + { + "name": "from Raw data to Importer 1", + "from": [ + "Raw data", + "right" + ], + "to": [ + "Importer 1", + "left" + ] + }, + { + "name": "from Importer 1 to DS1", + "from": [ + "Importer 1", + "right" + ], + "to": [ + "DS1", + "left" + ], + "options": { + "purge_before_writing": true, + "purge_settings": { + "alternative": true, + "scenario": true, + "scenario_alternative": true, + "entity_class": true, + "entity": true, + "entity_group": true, + "entity_alternative": true, + "parameter_value_list": true, + "list_value": true, + "parameter_definition": true, + "parameter_value": true, + "metadata": true, + "entity_metadata": true, + "parameter_value_metadata": true + }, + "write_index": 1 + } + } + ], + "jumps": [] + }, + "items": { + "Importer 1": { + "type": "Importer", + "description": "", + "x": 76.49024590313154, + "y": -79.19504574424269, + "specification": "Importer 1 - units.xlsx", + "cancel_on_error": true, + "on_conflict": "replace", + "file_selection": [ + [ + "/units.xlsx", + true + ] + ] + }, + "DS1": { + "type": "Data Store", + "description": "", + "x": 224.4333033622387, + "y": -154.05366960162178, + "url": { + "dialect": "sqlite", + "host": "", + "port": "", + "database": { + "type": "path", + "relative": true, + "path": ".spinetoolbox/items/ds1/DS1.sqlite" + } + } + }, + "Raw data": { + "type": "Data Connection", + "description": "", + "x": -83.2938157444066, + "y": -151.04282579168796, + "file_references": [], + "db_references": [] + } + } +} \ No newline at end of file diff --git a/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json b/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json new file mode 100644 index 000000000..94ec5e3dd --- /dev/null +++ b/execution_tests/simple_importer_on_server/Importer 1 - units.xlsx.json @@ -0,0 +1,44 @@ +{ + "name": "Importer 1 - units.xlsx", + "item_type": "Importer", + "mapping": { + "table_mappings": { + "Sheet1": [ + { + "": { + "mapping": [ + { + "map_type": "EntityClass", + "position": 0 + }, + { + "map_type": "Entity", + "position": 1 + }, + { + "map_type": "EntityMetadata", + "position": "hidden" + } + ] + } + } + ] + }, + "selected_tables": [ + "Sheet1" + ], + "table_options": { + "Sheet1": {} + }, + "table_types": { + "Sheet1": { + "0": "string", + "1": "string" + } + }, + "table_default_column_type": {}, + "table_row_types": {}, + "source_type": "ExcelConnector" + }, + "description": "" +} \ No newline at end of file diff --git a/execution_tests/simple_importer_on_server/__init__.py b/execution_tests/simple_importer_on_server/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/execution_tests/simple_importer_on_server/execution_test.py b/execution_tests/simple_importer_on_server/execution_test.py new file mode 100644 index 000000000..12c89bf70 --- /dev/null +++ b/execution_tests/simple_importer_on_server/execution_test.py @@ -0,0 +1,81 @@ +import os +import sys +from pathlib import Path +import subprocess +import unittest +import zmq +from spinetoolbox.config import PROJECT_ZIP_FILENAME +from spine_engine.server.engine_server import EngineServer, ServerSecurityModel +from spinedb_api import create_new_spine_database, DatabaseMapping + + +class RunSimpleImporterOnServer(unittest.TestCase): + _root_path = Path(__file__).parent + _db_path = _root_path / ".spinetoolbox" / "items" / "ds1" / "DS1.sqlite" + _zip_fname = PROJECT_ZIP_FILENAME + ".zip" + _zip_fpath = _root_path.parent / _zip_fname + + def setUp(self): + self.service = EngineServer("tcp", 50003, ServerSecurityModel.NONE, "") + self.context = zmq.Context() + self.socket = self.context.socket(zmq.DEALER) + self.socket.identity = "Worker1".encode("ascii") + self.socket.connect("tcp://localhost:50003") + self.pull_socket = self.context.socket(zmq.PULL) + self.poller = zmq.Poller() + self.poller.register(self.socket, zmq.POLLIN) + self.poller.register(self.pull_socket, zmq.POLLIN) + self.make_db_for_ds1(self._db_path) + + def tearDown(self): + self.service.close() + if not self.socket.closed: + self.socket.close() + if not self.pull_socket.closed: + self.pull_socket.close() + if not self.context.closed: + self.context.term() + if self._zip_fpath.exists(): + os.remove(self._zip_fpath) + + def make_db_for_ds1(self, p): + self._db_path.parent.mkdir(parents=True, exist_ok=True) + if self._db_path.exists(): + self._db_path.unlink() + self._db_url = "sqlite:///" + str(self._db_path) + create_new_spine_database(self._db_url) + + def test_execution(self): + # Check that DS1.sqlite is empty + with DatabaseMapping(self._db_url) as db_map: + entities = db_map.get_items("entity") + self.assertEqual(0, len(entities)) + completed = subprocess.run( + ( + sys.executable, + "-m", + "spinetoolbox", + "--execute-only", + "--execute-remotely", + "server.cfg", + str(self._root_path), + ) + ) + self.assertEqual(completed.returncode, 0) + # Check that entities are now in DB + with DatabaseMapping(self._db_url) as db_map: + entities = db_map.get_items("entity") + self.assertEqual(3, len(entities)) + for entity in entities: + if entity["id"].db_id == 1: + self.assertEqual("Factory1", entity["name"]) + elif entity["id"].db_id == 2: + self.assertEqual("Factory2", entity["name"]) + elif entity["id"].db_id == 3: + self.assertEqual("Factory3", entity["name"]) + else: + self.fail() + + +if __name__ == '__main__': + unittest.main() diff --git a/execution_tests/simple_importer_on_server/server.cfg b/execution_tests/simple_importer_on_server/server.cfg new file mode 100644 index 000000000..0b93b193f --- /dev/null +++ b/execution_tests/simple_importer_on_server/server.cfg @@ -0,0 +1,4 @@ +127.0.0.1 +50003 +off +./some/path/that/does/not/matter diff --git a/fig/add_create_dp_tool.png b/fig/add_create_dp_tool.png deleted file mode 100644 index 514569df9..000000000 Binary files a/fig/add_create_dp_tool.png and /dev/null differ diff --git a/fig/add_run_hydro_tool.png b/fig/add_run_hydro_tool.png deleted file mode 100644 index 748ce2809..000000000 Binary files a/fig/add_run_hydro_tool.png and /dev/null differ diff --git a/fig/add_tool_template.png b/fig/add_tool_template.png deleted file mode 100644 index 3fc471598..000000000 Binary files a/fig/add_tool_template.png and /dev/null differ diff --git a/fig/connections1.png b/fig/connections1.png deleted file mode 100644 index 012e1ceb1..000000000 Binary files a/fig/connections1.png and /dev/null differ diff --git a/fig/connections2.png b/fig/connections2.png deleted file mode 100644 index c6c30747f..000000000 Binary files a/fig/connections2.png and /dev/null differ diff --git a/fig/datapackage.png b/fig/datapackage.png deleted file mode 100644 index d5ce085a0..000000000 Binary files a/fig/datapackage.png and /dev/null differ diff --git a/fig/new_swedish_hydro.png b/fig/new_swedish_hydro.png deleted file mode 100644 index 3556b7295..000000000 Binary files a/fig/new_swedish_hydro.png and /dev/null differ diff --git a/fig/rawCSV.png b/fig/rawCSV.png deleted file mode 100644 index 5455264e6..000000000 Binary files a/fig/rawCSV.png and /dev/null differ diff --git a/fig/tool_templates.png b/fig/tool_templates.png deleted file mode 100644 index 046c6441b..000000000 Binary files a/fig/tool_templates.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index b59ffa1f0..cdf038686 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ ] requires-python = ">=3.8.1, <3.12" dependencies = [ - "pyside6 >= 6.5.0, != 6.5.3, < 6.6", + "pyside6 >= 6.5.0, != 6.5.3, != 6.6.3", "jupyter-client >=6.0", "qtconsole >=5.1", "sqlalchemy >=1.3", @@ -34,9 +34,6 @@ dependencies = [ Documentation = "https://spine-toolbox.readthedocs.io/" Repository = "https://github.com/spine-tools/Spine-Toolbox" -[project.optional-dependencies] -dev = ["coverage[toml]"] - [project.scripts] spinetoolbox = "spinetoolbox.main:main" spine-db-editor = "spinetoolbox.spine_db_editor.main:main" @@ -47,10 +44,8 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "spinetoolbox/_version.py" -# Temporarily set the version scheme to guess-next-dev for 0.7 because we have -# a separate 0.8-dev branch for 0.8. -# Normally we use release-branch-semver here. -version_scheme = "guess-next-dev" +# default: guess-next-dev, alternatives: post-release, no-guess-dev +version_scheme = "release-branch-semver" [tool.setuptools] zip-safe = false @@ -73,7 +68,7 @@ branch = true [tool.coverage.report] ignore_errors = true + [tool.black] line-length = 120 -skip-string-normalization = true exclude = '\.git|ui|resources_icons_rc.py|resources_logos_rc.py' diff --git a/requirements.txt b/requirements.txt index 0e40f2ce9..0ff41d492 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -e git+https://github.com/spine-tools/Spine-Database-API.git#egg=spinedb_api -e git+https://github.com/spine-tools/spine-engine.git#egg=spine_engine -e git+https://github.com/spine-tools/spine-items.git#egg=spine_items --e .[dev] +-e . diff --git a/spinetoolbox.py b/spinetoolbox.py index 7acac0f83..eaa17cfe5 100644 --- a/spinetoolbox.py +++ b/spinetoolbox.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox.spec b/spinetoolbox.spec new file mode 100644 index 000000000..1f7bcb990 --- /dev/null +++ b/spinetoolbox.spec @@ -0,0 +1,85 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### +""" +This is the PyInstaller spec file for Spine Toolbox. + +We bundle an embeddable Python interpreter with Toolbox +so all basic functionality should be available without the need to install Python. + +Steps to bundle Spine Toolbox: + +1. Activate Toolbox Python environment. +2. Install PyInstaller using Pip. +3. Download one of the 64-bit *embeddable* Python packages from https://www.python.org/downloads/windows/ +4. Unzip the downloaded package somewhere. +5. Run + +python -m PyInstaller spinetoolbox.spec -- --embedded-python= +""" + +import argparse +from pathlib import Path +import spinedb_api +import spine_engine +from spine_engine.config import BUNDLE_DIR +import spine_items + +parser = argparse.ArgumentParser() +parser.add_argument("--embedded-python", help="path to embedded Python interpreter", required=True) +options = parser.parse_args() +embedded_python_path = Path(options.embedded_python) +spinedb_api_path = Path(spinedb_api.__file__).parent.parent +spine_engine_path = Path(spine_engine.__file__).parent.parent +spine_items_path = Path(spine_items.__file__).parent.parent +data_file_target = Path() +data_files = ("CHANGELOG.md", "README.md", "COPYING", "COPYING.LESSER") +embedded_python_datas = [(str(path), BUNDLE_DIR) for path in embedded_python_path.iterdir()] +a = Analysis( + ['spinetoolbox.py'], + pathex=list(set(map(str, (spinedb_api_path, spine_engine_path, spine_items_path)))), + binaries=[], + datas=[(data_file, str(Path())) for data_file in data_files] + embedded_python_datas, + hiddenimports=[], + hookspath=["PyInstaller hooks"], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='spinetoolbox', + icon=Path("spinetoolbox", "ui", "resources", "app.ico"), + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Spine Toolbox', +) diff --git a/spinetoolbox/__init__.py b/spinetoolbox/__init__.py index ab8b72adc..dc2fd686b 100644 --- a/spinetoolbox/__init__.py +++ b/spinetoolbox/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,8 +10,6 @@ # this program. If not, see . ###################################################################################################################### -""" -spinetoolbox package. -""" +"""spinetoolbox package.""" from .version import __version__, __version_info__ diff --git a/spinetoolbox/__main__.py b/spinetoolbox/__main__.py index baf36aa05..1d259d4bd 100644 --- a/spinetoolbox/__main__.py +++ b/spinetoolbox/__main__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Spine Toolbox application main file. -""" - +"""Spine Toolbox application main file.""" import sys from spinetoolbox.main import main diff --git a/spinetoolbox/config.py b/spinetoolbox/config.py index b55e95ee1..4995d4580 100644 --- a/spinetoolbox/config.py +++ b/spinetoolbox/config.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,16 +10,12 @@ # this program. If not, see . ###################################################################################################################### -""" -Application constants and style sheets -""" - +"""Application constants and style sheets.""" import sys import os from pathlib import Path -# NOTE: All required Python package versions are in setup.cfg -LATEST_PROJECT_VERSION = 11 +LATEST_PROJECT_VERSION = 13 # For the Add/Update SpineOpt wizard REQUIRED_SPINE_OPT_VERSION = "0.6.9" @@ -26,9 +23,9 @@ # Invalid characters for directory names # NOTE: "." is actually valid in a directory name but this is # to prevent the user from creating directories like /..../ -INVALID_CHARS = ["<", ">", ":", "\"", "/", "\\", "|", "?", "*", "."] +INVALID_CHARS = ["<", ">", ":", '"', "/", "\\", "|", "?", "*", "."] # Invalid characters for file names -INVALID_FILENAME_CHARS = ["<", ">", ":", "\"", "/", "\\", "|", "?", "*"] +INVALID_FILENAME_CHARS = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"] # Paths to application, configuration file, default project and work dirs, and documentation index page _frozen = getattr(sys, "frozen", False) @@ -66,10 +63,12 @@ "QLabel{color: black;}" "QGroupBox{border: 2px solid gray; " "background-color: qlineargradient(x1: 0, y1: 0, x2: 1, y2: 0, stop: 0 #80B0FF, stop: 1 #e6efff);" + "font-size: 14px;" "border-radius: 5px;" "margin-top: 0.5em;}" "QGroupBox:title{border-radius: 2px; " - "background-color: ghostwhite;" + "background-color: white;" + "border-radius: 5px;" "subcontrol-origin: margin;" "subcontrol-position: top center;" "padding-top: 0px;" diff --git a/spinetoolbox/execution_managers.py b/spinetoolbox/execution_managers.py index f8c7f737c..038466a69 100644 --- a/spinetoolbox/execution_managers.py +++ b/spinetoolbox/execution_managers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes to manage tool instance execution in various forms. -""" - +"""Classes to manage tool instance execution in various forms.""" import logging from PySide6.QtCore import QObject, QProcess, Slot, Signal diff --git a/spinetoolbox/fetch_parent.py b/spinetoolbox/fetch_parent.py index e7641f107..912758ca7 100644 --- a/spinetoolbox/fetch_parent.py +++ b/spinetoolbox/fetch_parent.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,70 +10,69 @@ # this program. If not, see . ###################################################################################################################### -""" -The FetchParent and FlexibleFetchParent classes. -""" - -from PySide6.QtCore import QTimer, Signal, QObject +"""The FetchParent and FlexibleFetchParent classes.""" +from PySide6.QtCore import QTimer, Signal, QObject, Qt from .helpers import busy_effect class FetchParent(QObject): - """ - Attrs: - fetch_token (int or None) - will_have_children (bool or None): Whether this parent will have children if fetched. - None means we don't know yet. Set to a boolean value whenever we find out. - """ _changes_pending = Signal() - def __init__(self, owner=None, chunk_size=1000): + def __init__(self, index=None, owner=None, chunk_size=1000): """ Args: - owner (object): somebody who owns this FetchParent. If it's a QObject instance, then this FetchParent - becomes obsolete whenever the owner is destroyed - chunk_size (int or None): the number of items this parent should be happy with fetching at a time. - If None, then no limit is imposed and the parent should fetch the entire contents of the DB. + index (FetchIndex, optional): an index to speedup looking up fetched items + owner (object, optional): somebody who owns this FetchParent. + If it's a QObject instance, then this FetchParent becomes obsolete whenever the owner is destroyed + chunk_size (int, optional): the number of items this parent should be happy with fetching at a time. + If None, then no limit is imposed and the parent should fetch the entire contents of the DB. """ super().__init__() - self._timer = QTimer() - self._items_to_add = {} - self._items_to_update = {} - self._items_to_remove = {} + self._version = 0 + self._restore_item_callbacks = {} + self._update_item_callbacks = {} + self._remove_item_callbacks = {} + self._changes_by_db_map = {} self._obsolete = False self._fetched = False self._busy = False self._position = {} - self.fetch_token = None - self.will_have_children = None + self._timer = QTimer() self._timer.setSingleShot(True) self._timer.setInterval(0) self._timer.timeout.connect(self._apply_pending_changes) self._changes_pending.connect(self._timer.start) + self._index = index self._owner = owner if isinstance(self._owner, QObject): self._owner.destroyed.connect(lambda obj=None: self.set_obsolete(True)) + self.setParent(self._owner) self.chunk_size = chunk_size - def reset_fetching(self, fetch_token): - """Resets fetch parent as if nothing was ever fetched. + def apply_changes_immediately(self): + # For tests + self._changes_pending.connect(self._apply_pending_changes, Qt.UniqueConnection) - Args: - fetch_token (object): current fetch token - """ + @property + def index(self): + return self._index + + def reset(self): + """Resets fetch parent as if nothing was ever fetched.""" if self.is_obsolete: return + self._version += 1 + self._restore_item_callbacks.clear() + self._update_item_callbacks.clear() + self._remove_item_callbacks.clear() self._timer.stop() - self._items_to_add.clear() - self._items_to_update.clear() - self._items_to_remove.clear() + self._changes_by_db_map.clear() self._fetched = False self._busy = False self._position.clear() - self.fetch_token = fetch_token - self.will_have_children = None - self.will_have_children_change() + if self.index is not None: + self.index.reset() def position(self, db_map): return self._position.setdefault(db_map, 0) @@ -84,38 +84,83 @@ def increment_position(self, db_map): def _apply_pending_changes(self): if self.is_obsolete: return - for db_map in list(self._items_to_add): - data = self._items_to_add.pop(db_map) - self.handle_items_added({db_map: data}) - for db_map in list(self._items_to_update): - data = self._items_to_update.pop(db_map) - self.handle_items_updated({db_map: data}) - for db_map in list(self._items_to_remove): - data = self._items_to_remove.pop(db_map) - self.handle_items_removed({db_map: data}) + for db_map in list(self._changes_by_db_map): + changes = self._changes_by_db_map.pop(db_map) + last_handler = None + items = [] + for handler, item in changes: + if handler == last_handler: + items.append(item) + continue + if items: + last_handler({db_map: items}) # pylint: disable=not-callable + items = [item] + last_handler = handler + last_handler({db_map: items}) QTimer.singleShot(0, lambda: self.set_busy(False)) - def add_item(self, item, db_map): - self._items_to_add.setdefault(db_map, []).append(item) + def bind_item(self, item, db_map): + # NOTE: If `item` is in the process of calling callbacks in another thread, + # the ones added below won't be called. + # So, it is important to call this function before self.add_item() + item.add_restore_callback(self._make_restore_item_callback(db_map)) + item.add_update_callback(self._make_update_item_callback(db_map)) + item.add_remove_callback(self._make_remove_item_callback(db_map)) + + def _make_restore_item_callback(self, db_map): + if db_map not in self._restore_item_callbacks: + self._restore_item_callbacks[db_map] = _ItemCallback(self.add_item, db_map, self._version) + return self._restore_item_callbacks[db_map] + + def _make_update_item_callback(self, db_map): + if db_map not in self._update_item_callbacks: + self._update_item_callbacks[db_map] = _ItemCallback(self.update_item, db_map, self._version) + return self._update_item_callbacks[db_map] + + def _make_remove_item_callback(self, db_map): + if db_map not in self._remove_item_callbacks: + self._remove_item_callbacks[db_map] = _ItemCallback(self.remove_item, db_map, self._version) + return self._remove_item_callbacks[db_map] + + def _is_valid(self, version): + return (version is None or version == self._version) and not self.is_obsolete + + def _change_item(self, handler, item, db_map, version): + if not self._is_valid(version): + return False + self._changes_by_db_map.setdefault(db_map, []).append((handler, item)) self._changes_pending.emit() + return True - def update_item(self, item, db_map): - self._items_to_update.setdefault(db_map, []).append(item) - self._changes_pending.emit() + def add_item(self, item, db_map, version=None): + return self._change_item(self.handle_items_added, item, db_map, version) - def remove_item(self, item, db_map): - self._items_to_remove.setdefault(db_map, []).append(item) - self._changes_pending.emit() + def update_item(self, item, db_map, version=None): + return self._change_item(self.handle_items_updated, item, db_map, version) + + def remove_item(self, item, db_map, version=None): + return self._change_item(self.handle_items_removed, item, db_map, version) @property def fetch_item_type(self): - """Returns the type of item to fetch, e.g., "object_class". + """Returns the DB item type to fetch, e.g., "entity_class". Returns: str """ raise NotImplementedError() + def key_for_index(self, db_map): + """Returns the key for this parent in the index. + + Args: + db_map (DatabaseMapping) + + Returns: + Any + """ + return None + # pylint: disable=no-self-use def accepts_item(self, item, db_map): """Called by the associated SpineDBWorker whenever items are fetched and also added/updated/removed. @@ -126,7 +171,7 @@ def accepts_item(self, item, db_map): Args: item (dict): The item - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) Returns: bool @@ -140,16 +185,13 @@ def shows_item(self, item, db_map): Args: item (dict): The item - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) Returns: bool """ return True - def will_have_children_change(self): - """Called when the will_have_children property changes.""" - @property def is_obsolete(self): return self._obsolete @@ -160,6 +202,8 @@ def set_obsolete(self, obsolete): Args: obsolete (bool): whether parent has become obsolete """ + if obsolete: + self.set_busy(False) self._obsolete = obsolete @property @@ -172,6 +216,8 @@ def set_fetched(self, fetched): Args: fetched (bool): whether parent has been fetched completely """ + if fetched: + self.set_busy(False) self._fetched = fetched @property @@ -191,7 +237,7 @@ def handle_items_added(self, db_map_data): Called by SpineDBWorker when items are added to the DB. Args: - db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which + db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which ``accepts_item()`` returns True. """ raise NotImplementedError(self.fetch_item_type) @@ -201,7 +247,7 @@ def handle_items_removed(self, db_map_data): Called by SpineDBWorker when items are removed from the DB. Args: - db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which + db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which ``accepts_item()`` returns True. """ raise NotImplementedError(self.fetch_item_type) @@ -211,15 +257,15 @@ def handle_items_updated(self, db_map_data): Called by SpineDBWorker when items are updated in the DB. Args: - db_map_data (dict): Mapping DiffDatabaseMapping instances to list of dict-items for which + db_map_data (dict): Mapping DatabaseMapping instances to list of dict-items for which ``accepts_item()`` returns True. """ raise NotImplementedError(self.fetch_item_type) class ItemTypeFetchParent(FetchParent): - def __init__(self, fetch_item_type, owner=None, chunk_size=1000): - super().__init__(owner=owner, chunk_size=chunk_size) + def __init__(self, fetch_item_type, index=None, owner=None, chunk_size=1000): + super().__init__(index=index, owner=owner, chunk_size=chunk_size) self._fetch_item_type = fetch_item_type @property @@ -252,17 +298,23 @@ def __init__( handle_items_updated=None, accepts_item=None, shows_item=None, - will_have_children_change=None, + key_for_index=None, + index=None, owner=None, chunk_size=1000, ): - super().__init__(fetch_item_type, owner=owner, chunk_size=chunk_size) - self._accepts_item = accepts_item - self._shows_item = shows_item + super().__init__(fetch_item_type, index=index, owner=owner, chunk_size=chunk_size) self._handle_items_added = handle_items_added self._handle_items_removed = handle_items_removed self._handle_items_updated = handle_items_updated - self._will_have_children_change = will_have_children_change + self._accepts_item = accepts_item + self._shows_item = shows_item + self._key_for_index = key_for_index + + def key_for_index(self, db_map): + if self._key_for_index is None: + return None + return self._key_for_index(db_map) def handle_items_added(self, db_map_data): if self._handle_items_added is None: @@ -289,8 +341,36 @@ def shows_item(self, item, db_map): return super().shows_item(item, db_map) return self._shows_item(item, db_map) - def will_have_children_change(self): - if self._will_have_children_change is None: - super().will_have_children_change() - else: - self._will_have_children_change() + +class FetchIndex(dict): + def __init__(self): + super().__init__() + self._position = {} + + def reset(self): + self._position.clear() + self.clear() + + def process_item(self, item, db_map): + raise NotImplementedError() + + def position(self, db_map): + return self._position.setdefault(db_map, 0) + + def increment_position(self, db_map): + self._position[db_map] += 1 + + def get_items(self, key, db_map): + return self.get(db_map, {}).get(key, []) + + +class _ItemCallback: + def __init__(self, fn, *args): + self._fn = fn + self._args = args + + def __call__(self, item): + return self._fn(item, *self._args) + + def __str__(self): + return str(self._fn) + " with " + str(self._args) diff --git a/spinetoolbox/headless.py b/spinetoolbox/headless.py index 783bfbb7e..1147b6375 100644 --- a/spinetoolbox/headless.py +++ b/spinetoolbox/headless.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains facilities to open and execute projects without GUI. -""" +"""Contains facilities to open and execute projects without GUI.""" +import os from copy import deepcopy from enum import IntEnum, unique import json @@ -19,13 +19,15 @@ import sys from PySide6.QtCore import QCoreApplication, QEvent, QObject, QSettings, Signal, Slot import networkx as nx - from spine_engine import SpineEngineState from spine_engine.exception import EngineInitFailed from spine_engine.load_project_items import load_item_specification_factories from spine_engine.utils.serialization import deserialize_path +from spine_engine.utils.helpers import get_file_size, ExecutionDirection +from spine_engine.server.util.zip_handler import ZipHandler +from .server.engine_client import EngineClient, RemoteEngineInitFailed, ClientSecurityModel from .project_item.logging_connection import HeadlessConnection -from .config import LATEST_PROJECT_VERSION +from .config import LATEST_PROJECT_VERSION, PROJECT_ZIP_FILENAME from .helpers import ( make_settings_dict_for_engine, plugins_dirs, @@ -193,6 +195,7 @@ def __init__(self, args, startup_event_type, parent): self._plugin_specifications = None self._connection_dicts = None self._jump_dicts = None + self._server_config = None def _dags(self): graph = nx.DiGraph() @@ -210,6 +213,12 @@ def _execute(self): return try: status = self._open_project() + if self._args.execute_remotely: + self._server_config = self._read_server_config() + if not self._server_config: + self._logger.msg_error.emit("Reading server config file failed.") + QCoreApplication.instance().exit(Status.ARGUMENT_ERROR) + return if status != Status.OK: QCoreApplication.instance().exit(status) return @@ -319,9 +328,16 @@ def _execute_project(self): spec_dict["definition_file_path"] = spec.definition_file_path self._specification_dicts.setdefault(item_type, []).append(spec_dict) dags = self._dags() + job_id = self._prepare_remote_execution() + if not job_id: + self._logger.msg_error.emit("Pinging the server or uploading the project failed.") + return Status.ERROR settings = make_settings_dict_for_engine(self._app_settings) - # Force local execution in headless mode - if not settings.get("engineSettings/remoteExecutionEnabled", "false") == "false": + # Enable remote execution if server config file was given, else force local execution + if self._server_config is not None: + settings["engineSettings/remoteExecutionEnabled"] = "true" + settings = self._insert_remote_engine_settings(settings) + else: settings["engineSettings/remoteExecutionEnabled"] = "false" selected = {name for name_list in self._args.select for name in name_list} if self._args.select else None deselected = {name for name_list in self._args.deselect for name in name_list} if self._args.deselect else None @@ -352,11 +368,10 @@ def _execute_project(self): "execution_permits": execution_permits, "items_module_name": "spine_items", "settings": settings, - "project_dir": self._project_dir, + "project_dir": solve_project_dir(self._project_dir), } - # exec_remotely is forced to False (see above) - exec_remotely = settings.get("engineSettings/remoteExecutionEnabled", "false") == "true" - engine_manager = make_engine_manager(exec_remotely) + exec_remotely = True if self._server_config else False + engine_manager = make_engine_manager(exec_remotely, job_id=job_id) try: engine_manager.run_engine(engine_data) except EngineInitFailed as error: @@ -390,6 +405,7 @@ def _process_engine_event(self, event_type, data): "standard_execution_msg": self._handle_standard_execution_msg, "persistent_execution_msg": self._handle_persistent_execution_msg, "kernel_execution_msg": self._handle_kernel_execution_msg, + "server_status_msg": self._handle_server_status_msg, }.get(event_type) if handler is None: return @@ -408,7 +424,7 @@ def _handle_node_execution_started(self, data): Args: data (dict): execution start data """ - if data["direction"] == "BACKWARD": + if data["direction"] == ExecutionDirection.BACKWARD: # Currently there are no interesting messages when executing backwards. return self._node_messages[data["item_name"]] = dict() @@ -482,6 +498,103 @@ def _handle_kernel_execution_msg(self, data): data (dict): message data """ + def _handle_server_status_msg(self, data): + """Handles received remote execution messages.""" + if data["msg_type"] == "success": + self._logger.msg_success.emit(data["text"]) + elif data["msg_type"] == "neutral": + self._logger.msg.emit(data["text"]) + elif data["msg_type"] == "fail": + self._logger.msg_error.emit(data["text"]) + elif data["msg_type"] == "warning": + self._logger.msg_warning.emit(data["text"]) + + def _read_server_config(self): + """Reads the user provided server settings file that the client requires to establish connection. + + Returns: + dict: Dictionary containing the EngineClient settings or None if the given config file does not exist. + """ + cfg_file = self._args.execute_remotely[0] + cfg_fp = os.path.join(self._project_dir, cfg_file) + if os.path.isfile(cfg_fp): + with open(cfg_fp, encoding="utf-8") as fp: + lines = fp.readlines() + lines = [l.strip() for l in lines] + host = lines[0] + port = lines[1] + smodel = lines[2] + rel_sec_folder = lines[3] + sec_model = "stonehouse" if smodel.lower() == "on" else "" + if sec_model == "stonehouse": + sec_folder = os.path.abspath(os.path.join(solve_project_dir(self._project_dir), rel_sec_folder)) + else: + sec_folder = "" + cfg_dict = {"host": host, "port": port, "security_model": sec_model, "security_folder": sec_folder} + return cfg_dict + else: + self._logger.msg_error.emit(f"cfg file '{cfg_fp}' missing.") + return None + + def _insert_remote_engine_settings(self, settings): + """Inserts remote engine client settings into the settings dictionary that is delivered to the engine. + + Args: + settings (dict): Original settings dictionary + + Returns: + dict: Settings dictionary containing remote engine client settings + """ + settings["engineSettings/remoteHost"] = self._server_config["host"] + settings["engineSettings/remotePort"] = self._server_config["port"] + settings["engineSettings/remoteSecurityModel"] = self._server_config["security_model"] + settings["engineSettings/remoteSecurityFolder"] = self._server_config["security_folder"] + return settings + + def _prepare_remote_execution(self): + """If remote execution is enabled, makes an EngineClient for pinging and uploading the project. + If ping is successful, the project is uploaded to the server. If the upload is successful, the + server responds with a Job id, which is later used by the client to make a 'start execution' + request. + + Returns: + str: Job id if server is ready for remote execution, empty string if something went wrong + or "1" if local execution is enabled. + """ + if not self._server_config: + return "1" + host, port = self._server_config["host"], self._server_config["port"] + security_on = False if self._server_config["security_model"].lower() == "" else True + sec_model = ClientSecurityModel.STONEHOUSE if security_on else ClientSecurityModel.NONE + try: + engine_client = EngineClient(host, port, sec_model, self._server_config["security_folder"]) + except RemoteEngineInitFailed as e: + self._logger.msg_error.emit(f"Server is not responding in {host}:{port}. {e}.") + return "" + engine_client.set_start_time() # Set start_time for upload operation + # Archive the project into a zip-file + dest_dir = os.path.join(self._project_dir, os.pardir) # Parent dir of project_dir + _, project_name = os.path.split(self._project_dir) + self._logger.msg.emit(f"Squeezing project {project_name} into {PROJECT_ZIP_FILENAME}.zip") + try: + ZipHandler.package(src_folder=self._project_dir, dst_folder=dest_dir, fname=PROJECT_ZIP_FILENAME) + except Exception as e: + self._logger.msg_error.emit(f"{e}") + engine_client.close() + return "" + project_zip_file = os.path.abspath(os.path.join(self._project_dir, os.pardir, PROJECT_ZIP_FILENAME + ".zip")) + if not os.path.isfile(project_zip_file): + self._logger.msg_error.emit(f"Project zip-file {project_zip_file} does not exist") + engine_client.close() + return "" + file_size = get_file_size(os.path.getsize(project_zip_file)) + self._logger.msg_warning.emit(f"Uploading project [{file_size}] ...") + job_id = engine_client.upload_project(project_name, project_zip_file) + t = engine_client.get_elapsed_time() + self._logger.msg.emit(f"Upload time: {t}. Job ID: {job_id}") + engine_client.close() + return job_id + def headless_main(args): """ @@ -551,6 +664,18 @@ def _specification_dicts(project_dict, project_dir, logger): return specification_dicts +def solve_project_dir(pd): + """Makes given path object OS independent. + + Args: + pd (Path): Path Object + + Returns: + str: OS independent path as string. + """ + return str(pd).replace(os.sep, "/") + + @unique class Status(IntEnum): """Status codes returned from headless execution.""" diff --git a/spinetoolbox/helpers.py b/spinetoolbox/helpers.py index b7db7e840..3666c53b4 100644 --- a/spinetoolbox/helpers.py +++ b/spinetoolbox/helpers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -General helper functions and classes. -""" +"""General helper functions and classes.""" import functools import time from enum import Enum, unique @@ -28,11 +27,13 @@ import bisect from contextlib import contextmanager import tempfile +from typing import Sequence + import matplotlib from PySide6.QtCore import Qt, Slot, QFile, QIODevice, QSize, QRect, QPoint, QUrl, QObject, QEvent from PySide6.QtCore import __version__ as qt_version from PySide6.QtCore import __version_info__ as qt_version_info -from PySide6.QtWidgets import QApplication, QMessageBox, QFileIconProvider, QStyle, QFileDialog, QInputDialog +from PySide6.QtWidgets import QApplication, QMessageBox, QFileIconProvider, QStyle, QFileDialog, QInputDialog, QSplitter from PySide6.QtGui import ( QGuiApplication, QCursor, @@ -51,9 +52,11 @@ QColor, QFont, QPainter, + QUndoCommand, ) from spine_engine.utils.serialization import deserialize_path from spinedb_api.spine_io.gdx_utils import find_gams_directory +from spinedb_api.helpers import group_consecutive from .config import ( DEFAULT_WORK_DIR, PLUGINS_PATH, @@ -66,7 +69,7 @@ if os.name == "nt": import ctypes -matplotlib.use('Qt5Agg') +matplotlib.use("Qt5Agg") matplotlib.rcParams.update({"font.size": 8}) logging.getLogger("matplotlib").setLevel(logging.WARNING) _matplotlib_version = [int(x) for x in matplotlib.__version__.split(".") if x.isdigit()] @@ -147,7 +150,7 @@ def create_dir(base_path, folder="", verbosity=False): def rename_dir(old_dir, new_dir, toolbox, box_title): - """Renames directory. Called by ``ProjectItemModel.set_item_name()`` + """Renames directory. Args: old_dir (str): Absolute path to directory that will be renamed @@ -238,7 +241,7 @@ def set_taskbar_icon(): def supported_img_formats(): """Checks if reading .ico files is supported.""" img_formats = QImageReader().supportedImageFormats() - img_formats_str = '\n'.join(str(x) for x in img_formats) + img_formats_str = "\n".join(str(x) for x in img_formats) logging.debug("Supported Image formats:\n%s", img_formats_str) @@ -300,7 +303,7 @@ def copy_files(src_dir, dst_dir, includes=None, excludes=None): count (int): Number of files copied """ if includes is None: - includes = ['*'] + includes = ["*"] if excludes is None: excludes = [] src_files = [] @@ -406,8 +409,7 @@ def format_string_list(str_list): def rows_to_row_count_tuples(rows): - """Breaks a list of rows into a list of (row, count) tuples to corresponding - chunks of successive rows. + """Breaks a list of rows into a list of (row, count) tuples corresponding to chunks of successive rows. Args: rows (Iterable of int): rows @@ -415,14 +417,7 @@ def rows_to_row_count_tuples(rows): Returns: list of tuple: row count tuples """ - rows = set(rows) - if not rows: - return [] - sorted_rows = sorted(rows) - break_points = [k + 1 for k in range(len(sorted_rows) - 1) if sorted_rows[k] + 1 != sorted_rows[k + 1]] - break_points = [0] + break_points + [len(sorted_rows)] - ranges = [(break_points[l], break_points[l + 1]) for l in range(len(break_points) - 1)] - return [(sorted_rows[start], stop - start) for start, stop in ranges] + return [(first, last - first + 1) for first, last in group_consecutive(rows)] class IconListManager: @@ -511,7 +506,7 @@ def __init__(self, char, color=None): super().__init__() self.char = char self.color = QColor(color) - self.font = QFont('Font Awesome 5 Free Solid') + self.font = QFont("Font Awesome 5 Free Solid") def paint(self, painter, rect, mode=None, state=None): painter.save() @@ -871,8 +866,8 @@ def select_conda_executable(parent, line_edit): if answer[0] == "": # Canceled return # Check that selected file at least starts with string 'conda' - _, selected_file = os.path.split(answer[0]) - if not selected_file.lower().startswith("conda"): + if not is_valid_conda_executable(answer[0]): + _, selected_file = os.path.split(answer[0]) msg = "Selected file {0} is not a valid Conda executable".format(selected_file) # noinspection PyCallByClass, PyArgumentList QMessageBox.warning(parent, "Invalid Conda selected", msg) @@ -880,6 +875,20 @@ def select_conda_executable(parent, line_edit): line_edit.setText(answer[0]) +def is_valid_conda_executable(p): + """Checks that given path points to an existing file and the file name starts with 'conda'. + + Args: + p (str): Absolute path to a file + """ + if not os.path.isfile(p): + return False + _, filename = os.path.split(p) + if not filename.lower().startswith("conda"): + return False + return True + + def select_certificate_directory(parent, line_edit): """Shows file browser and inserts selected certificate directory to given line edit. @@ -1011,11 +1020,11 @@ def make_icon_toolbar_ss(color): return f"QToolBar{{spacing: 0px; background: {icon_background}; padding: 3px; border-style: solid;}}" -def color_from_index(i, count, base_hue=0.0, saturation=1.0): +def color_from_index(i, count, base_hue=0.0, saturation=1.0, value=1.0): golden_ratio = 0.618033988749895 h = golden_ratio * (360 / count) * i h = ((base_hue + h) % 360) / 360 - return QColor.fromHsvF(h, saturation, 1.0, 1.0) + return QColor.fromHsvF(h, saturation, value, 1.0) def unique_name(prefix, existing): @@ -1052,21 +1061,6 @@ def unique_name(prefix, existing): return f"{prefix} ({free})" -def get_upgrade_db_promt_text(url, current, expected): - text = ( - f"The database at {url} is at revision {current} and needs to be " - f"upgraded to revision {expected} in order to be used with the current " - "version of Spine Toolbox." - ) - info_text = ( - "WARNING: After the upgrade, " - "the database may no longer be used " - "with previous versions of Spine." - "

Do you want to upgrade the database now?" - ) - return text, info_text - - def parse_specification_file(spec_path, logger): """Parses specification file. @@ -1306,6 +1300,16 @@ def wait(self): @contextmanager def signal_waiter(signal, condition=None, timeout=None): + """Gives a context manager that waits for the emission of given Qt signal. + + Args: + signal (Any): signal to wait + condition (Callable, optional): a callable that takes the signal's parameters and returns True to stop waiting + timeout (float, optional): timeout in seconds; if None, wait indefinitely + + Yields: + SignalWaiter: waiter instance + """ waiter = SignalWaiter(condition=condition, timeout=timeout) signal.connect(waiter.trigger) try: @@ -1329,17 +1333,17 @@ def set_style(self, style): self._formats.clear() for ttype, tstyle in style: text_format = self._formats[ttype] = QTextCharFormat() - if tstyle['color']: - brush = QBrush(QColor("#" + tstyle['color'])) + if tstyle["color"]: + brush = QBrush(QColor("#" + tstyle["color"])) text_format.setForeground(brush) - if tstyle['bgcolor']: - brush = QBrush(QColor("#" + tstyle['bgcolor'])) + if tstyle["bgcolor"]: + brush = QBrush(QColor("#" + tstyle["bgcolor"])) text_format.setBackground(brush) - if tstyle['bold']: + if tstyle["bold"]: text_format.setFontWeight(QFont.Bold) - if tstyle['italic']: + if tstyle["italic"]: text_format.setFontItalic(True) - if tstyle['underline']: + if tstyle["underline"]: text_format.setFontUnderline(True) def yield_formats(self, text): @@ -1391,8 +1395,11 @@ def restore_ui(window, app_settings, settings_group): window_size = app_settings.value("windowSize") window_pos = app_settings.value("windowPosition") window_state = app_settings.value("windowState") - window_maximized = app_settings.value("windowMaximized", defaultValue='false') + window_maximized = app_settings.value("windowMaximized", defaultValue="false") n_screens = app_settings.value("n_screens", defaultValue=1) + splitter_states = { + splitter: app_settings.value(splitter.objectName() + "State") for splitter in window.findChildren(QSplitter) + } app_settings.endGroup() original_size = window.size() if window_size: @@ -1405,8 +1412,10 @@ def restore_ui(window, app_settings, settings_group): if len(QGuiApplication.screens()) < int(n_screens): # There are less screens available now than on previous application startup window.move(0, 0) # Move this widget to primary screen position (0,0) + for splitter, state in splitter_states.items(): + splitter.restoreState(state) ensure_window_is_on_screen(window, original_size) - if window_maximized == 'true': + if window_maximized == "true": window.setWindowState(Qt.WindowMaximized) @@ -1424,6 +1433,8 @@ def save_ui(window, app_settings, settings_group): app_settings.setValue("windowState", window.saveState(version=1)) app_settings.setValue("windowMaximized", window.windowState() == Qt.WindowMaximized) app_settings.setValue("n_screens", len(QGuiApplication.screens())) + for splitter in window.findChildren(QSplitter): + app_settings.setValue(splitter.objectName() + "State", splitter.saveState()) app_settings.endGroup() @@ -1549,31 +1560,6 @@ def _is_metadata_item(item): return "name" in item and "value" in item -def separate_metadata_and_item_metadata(db_map_data): - """Separates normal metadata items from item metadata items. - - Args: - db_map_data (dict): database records - - Returns: - tuple: item metadata records and metadata records - """ - metadata_db_map_data = {} - item_metadata_db_map_data = {} - for db_map, items in db_map_data.items(): - metadata_items = [] - entity_metadata_items = [] - for item in items: - if _is_metadata_item(item): - metadata_items.append(item) - else: - entity_metadata_items.append(item) - if metadata_items: - metadata_db_map_data[db_map] = metadata_items - item_metadata_db_map_data[db_map] = entity_metadata_items - return item_metadata_db_map_data, metadata_db_map_data - - class HTMLTagFilter(HTMLParser): """HTML tag filter.""" @@ -1630,3 +1616,66 @@ def solve_connection_file(connection_file, connection_file_dict): fp.close() return connection_file return connection_file + + +def remove_first(lst, items): + for x in items: + try: + lst.remove(x) + break + except ValueError: + pass + + +class SealCommand(QUndoCommand): + """A 'meta' command that does not store undo data but can be used in mergeWith methods of other commands.""" + + def __init__(self, command_id=1): + """ + Args: + command_id (int): command id + """ + super().__init__("") + self._id = command_id + + def redo(self): + self.setObsolete(True) + + def id(self): + return self._id + + +def plain_to_rich(text): + """Turns plain strings into rich text. + + Args: + text (str): string to convert + + Returns: + str: rich text string + """ + return "" + text + "" + + +def list_to_rich_text(data): + """Turns a sequence of strings into rich text list. + + Args: + data (Sequence of str): iterable to convert + + Returns: + str: rich text string + """ + return plain_to_rich("
".join(data)) + + +def plain_to_tool_tip(text): + """Turns plain strings into rich text and empty strings/Nones to None. + + Args: + text (str, optional): string to convert + + Returns: + str or NoneType: rich text string or None + """ + return plain_to_rich(text) if text else None diff --git a/spinetoolbox/kernel_fetcher.py b/spinetoolbox/kernel_fetcher.py index be1b52943..cf4e3b9ff 100644 --- a/spinetoolbox/kernel_fetcher.py +++ b/spinetoolbox/kernel_fetcher.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -10,7 +11,6 @@ ###################################################################################################################### """Contains a class for fetching kernel specs in a thread.""" - import os import json from PySide6.QtCore import Signal, Slot, QThread diff --git a/spinetoolbox/link.py b/spinetoolbox/link.py index 42562a6bd..0cc31a2a9 100644 --- a/spinetoolbox/link.py +++ b/spinetoolbox/link.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for drawing graphics items on QGraphicsScene. -""" - +"""Classes for drawing graphics items on QGraphicsScene.""" import functools from math import sin, cos, pi, radians from PySide6.QtCore import Qt, Slot, QPointF, QLineF, QRectF, QVariantAnimation @@ -288,7 +286,7 @@ def hoverLeaveEvent(self, event): class _SvgIcon(_IconBase): - """A svg icon to show over a Link.""" + """An SVG icon to show over a Link.""" def __init__(self, parent, extent, path, tooltip=None, active=False): super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active) @@ -317,7 +315,7 @@ class _TextIcon(_IconBase): def __init__(self, parent, extent, char, tooltip=None, active=False): super().__init__(0, 0, extent, extent, parent, tooltip=tooltip, active=active) self._text_item = QGraphicsTextItem(self) - font = QFont('Font Awesome 5 Free Solid', weight=QFont.Bold) + font = QFont("Font Awesome 5 Free Solid", weight=QFont.Bold) self._text_item.setFont(font) self._text_item.setDefaultTextColor(self._fg_color) self._text_item.setPlainText(char) @@ -370,7 +368,7 @@ def _place_icons(self): if icon_count == 1: self._icons[0].setPos(center - offset) return - points = list(_regular_poligon_points(icon_count, icon_extent, self._guide_path.angleAtPercent(0.5))) + points = list(_regular_polygon_points(icon_count, icon_extent, self._guide_path.angleAtPercent(0.5))) points_center = functools.reduce(lambda a, b: a + b, points) / icon_count offset += points_center - center scale = icon_extent / self._icon_extent @@ -628,12 +626,14 @@ def wake_up(self, src_connector): view = self._toolbox.ui.graphicsView self.tip = view.mapToScene(view.mapFromGlobal(QCursor.pos())) self.src_connector = src_connector - self.src_connector.scene().addItem(self) + scene = self.src_connector.scene() + scene.addItem(self) self._stroker.setWidth(self.magic_number) self._pen.setWidthF(self.magic_number) self.setPen(self._pen) self.update_geometry() self.show() + scene.link_about_to_be_drawn.emit() def sleep(self): """Removes this drawer from the scene, clears its source and destination connectors, and hides it. @@ -643,6 +643,7 @@ def sleep(self): scene.removeItem(self) scene.link_drawer = self.src_connector = self.dst_connector = self.tip = None self.hide() + scene.link_drawing_finished.emit() class ConnectionLinkDrawer(LinkDrawerBase): @@ -689,7 +690,7 @@ def add_link(self): self.sleep() -def _regular_poligon_points(n, side, initial_angle=0): +def _regular_polygon_points(n, side, initial_angle=0): internal_angle = 180 * (n - 2) / n angle_inc = 180 - internal_angle current_angle = initial_angle diff --git a/spinetoolbox/load_project_items.py b/spinetoolbox/load_project_items.py index e66092982..dab524c9a 100644 --- a/spinetoolbox/load_project_items.py +++ b/spinetoolbox/load_project_items.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,23 +10,12 @@ # this program. If not, see . ###################################################################################################################### -""" -Functions to load project item modules. -""" -import pathlib +""" Functions to load project item modules. """ import importlib -import importlib.util -import pkgutil -from spine_engine.project_item.project_item_info import ProjectItemInfo -from spine_engine import __version__ as curr_engine_version -from spinedb_api import __version__ as curr_db_api_version -from .version import __version__ as curr_toolbox_version -from .project_item.project_item_factory import ProjectItemFactory def load_project_items(items_package_name): - """ - Loads project item modules. + """Loads project item modules. Args: items_package_name (str): name of the package that contains the project items @@ -35,38 +25,4 @@ def load_project_items(items_package_name): while second maps item type to item factory """ items = importlib.import_module(items_package_name) - items_root = pathlib.Path(items.__file__).parent - categories = dict() - factories = dict() - for child in items_root.iterdir(): - if child.is_dir() and (child.joinpath("__init__.py").exists() or child.joinpath("__init__.pyc").exists()): - spec = importlib.util.find_spec(f"{items_package_name}.{child.stem}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - module_material = _find_module_material(module) - if module_material is not None: - item_type, category, factory = module_material - categories[item_type] = category - factories[item_type] = factory - return categories, factories - - -def _find_module_material(module): - item_type = None - category = None - factory = None - prefix = module.__name__ + "." - for _, modname, _ in pkgutil.iter_modules(module.__path__, prefix): - submodule = __import__(modname, fromlist="dummy") - for name in dir(submodule): - attr = getattr(submodule, name) - if not isinstance(attr, type): - continue - if attr is not ProjectItemInfo and issubclass(attr, ProjectItemInfo): - item_type = attr.item_type() - category = attr.item_category() - if attr is not ProjectItemFactory and issubclass(attr, ProjectItemFactory): - factory = attr - if item_type is not None and factory is not None: - return item_type, category, factory - return None + return items.item_factories() diff --git a/spinetoolbox/log_mixin.py b/spinetoolbox/log_mixin.py index 27a376960..e09aceae4 100644 --- a/spinetoolbox/log_mixin.py +++ b/spinetoolbox/log_mixin.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,10 +9,8 @@ # 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 . ###################################################################################################################### -""" -Contains LogMixin. -""" +"""Contains LogMixin.""" from .helpers import format_log_message diff --git a/spinetoolbox/logger_interface.py b/spinetoolbox/logger_interface.py index e510f449d..798575e16 100644 --- a/spinetoolbox/logger_interface.py +++ b/spinetoolbox/logger_interface.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A logger interface. -""" - +"""A logger interface.""" from PySide6.QtCore import QObject, Signal diff --git a/spinetoolbox/main.py b/spinetoolbox/main.py index efd598418..88e40c30a 100644 --- a/spinetoolbox/main.py +++ b/spinetoolbox/main.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Provides the main() function. -""" +"""Provides the main() function.""" import os import multiprocessing import PySide6 @@ -44,15 +43,15 @@ def main(): logging.basicConfig( stream=sys.stderr, level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) if not pyside6_version_check(): return 1 _add_pywin32_system32_to_path() parser = _make_argument_parser() args = parser.parse_args() - if args.execute_only or args.list_items: + if args.execute_only or args.list_items or args.execute_remotely: return_code = headless_main(args) if return_code == Status.ARGUMENT_ERROR: parser.print_usage() @@ -94,10 +93,11 @@ def _make_argument_parser(): "-d", "--deselect", action="append", - help="deselect project item ITEM for execution (takes precendence over --select)", + help="deselect project item ITEM for execution (takes precedence over --select)", nargs="*", metavar="ITEM", ) + parser.add_argument("--execute-remotely", help="execute remotely", action="append", metavar="SERVER CONFIG FILE") return parser diff --git a/spinetoolbox/metaobject.py b/spinetoolbox/metaobject.py index 55264f250..4b98e9718 100644 --- a/spinetoolbox/metaobject.py +++ b/spinetoolbox/metaobject.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,18 +10,16 @@ # this program. If not, see . ###################################################################################################################### -""" -MetaObject class. -""" - +"""MetaObject class.""" from PySide6.QtCore import QObject from spine_engine.utils.helpers import shorten class MetaObject(QObject): - def __init__(self, name, description): - """Class for an object which has a name, type, and some description. + """Class for an object which has a name, type, and some description.""" + def __init__(self, name, description): + """ Args: name (str): Object name description (str): Object description diff --git a/spinetoolbox/mvcmodels/__init__.py b/spinetoolbox/mvcmodels/__init__.py index 8b96d2413..336d85acc 100644 --- a/spinetoolbox/mvcmodels/__init__.py +++ b/spinetoolbox/mvcmodels/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/mvcmodels/array_model.py b/spinetoolbox/mvcmodels/array_model.py index 00cfd6d53..f8d173e4d 100644 --- a/spinetoolbox/mvcmodels/array_model.py +++ b/spinetoolbox/mvcmodels/array_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,14 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains model for the Array editor widget. -""" +"""Contains model for the Array editor widget.""" import locale from numbers import Number import numpy from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt from spinedb_api import Array, from_database, ParameterValueFormatError, SpineDBAPIError from .indexed_value_table_model import EXPANSE_COLOR +from ..helpers import plain_to_tool_tip class ArrayModel(QAbstractTableModel): @@ -145,7 +145,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): if row == len(self._data): return None element = self._data[row] - return str(element) + return plain_to_tool_tip(str(element)) if role == Qt.ItemDataRole.BackgroundRole and row == len(self._data): return EXPANSE_COLOR return None diff --git a/spinetoolbox/mvcmodels/compound_table_model.py b/spinetoolbox/mvcmodels/compound_table_model.py index be9e1b84b..614874215 100644 --- a/spinetoolbox/mvcmodels/compound_table_model.py +++ b/spinetoolbox/mvcmodels/compound_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Models that vertically concatenate two or more table models. -""" - +"""Models that vertically concatenate two or more table models.""" import bisect from PySide6.QtCore import Qt, Signal, Slot, QModelIndex, QTimer from ..mvcmodels.minimal_table_model import MinimalTableModel @@ -385,9 +383,10 @@ def _get_row_for_insertion(self, pos): def _insert_row_map(self, pos, single_row_map): if not single_row_map: - # To trigger fetching. The QTimer is to avoid funny situations where the user enters new data - # via the empty row model, and those rows need to be removed at the same time as we fetch the added data. - # Doing it in the same loop cycle causes bugs. + # Emit layoutChanged to trigger fetching. + # The QTimer is to avoid funny situations where the user enters new data via the empty row model, + # and those rows need to be removed at the same time as we fetch the added data. + # Doing it in the same loop cycle was causing bugs. QTimer.singleShot(0, self.layoutChanged.emit) return row = self._get_row_for_insertion(pos) diff --git a/spinetoolbox/mvcmodels/empty_row_model.py b/spinetoolbox/mvcmodels/empty_row_model.py index b0edf36d6..7ae1c6e8f 100644 --- a/spinetoolbox/mvcmodels/empty_row_model.py +++ b/spinetoolbox/mvcmodels/empty_row_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a table model with an empty last row. -""" - +""" Contains a table model with an empty last row. """ from PySide6.QtCore import Qt, Slot, QModelIndex from .minimal_table_model import MinimalTableModel diff --git a/spinetoolbox/mvcmodels/file_list_models.py b/spinetoolbox/mvcmodels/file_list_models.py index b83ae4d79..75fa33642 100644 --- a/spinetoolbox/mvcmodels/file_list_models.py +++ b/spinetoolbox/mvcmodels/file_list_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Items. # Spine Items 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) @@ -9,12 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Common models. -Contains a generic File list model and an Item for that model. -Used by the Importer and Tool project items but this may be handy for other project items -as well. -""" +"""Contains a generic File list model and an Item for that model.""" from collections import namedtuple from itertools import combinations, takewhile import json @@ -23,6 +19,7 @@ from PySide6.QtWidgets import QFileIconProvider from PySide6.QtGui import QStandardItemModel, QStandardItem, QPixmap, QPainter, QIcon, QColor from spine_engine.project_item.project_item_resource import extract_packs, CmdLineArg, LabelArg +from spinetoolbox.helpers import plain_to_rich class FileListModel(QAbstractItemModel): @@ -100,7 +97,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): return ( resource.path if resource.hasfilepath - else f"This file will be generated by {resource.provider_name} upon execution." + else plain_to_rich(f"This file will be generated by {resource.provider_name} upon execution.") ) return None diff --git a/spinetoolbox/mvcmodels/filter_checkbox_list_model.py b/spinetoolbox/mvcmodels/filter_checkbox_list_model.py index 24345b4dc..6c030d8a8 100644 --- a/spinetoolbox/mvcmodels/filter_checkbox_list_model.py +++ b/spinetoolbox/mvcmodels/filter_checkbox_list_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,26 +10,23 @@ # this program. If not, see . ###################################################################################################################### -""" -Provides FilterCheckboxListModel for FilterWidget. -""" - +"""Provides FilterCheckboxListModel for FilterWidget.""" import re -from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel +from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel, Slot from spinetoolbox.helpers import bisect_chunks class SimpleFilterCheckboxListModel(QAbstractListModel): - _SELECT_ALL_STR = '(Select all)' - _SELECT_ALL_FILTERED_STR = '(Select all filtered)' - _EMPTY_STR = '(Empty)' - _ADD_TO_SELECTION_STR = 'Add current selection to filter' + _SELECT_ALL_STR = "(Select all)" + _SELECT_ALL_FILTERED_STR = "(Select all filtered)" + _EMPTY_STR = "(Empty)" + _ADD_TO_SELECTION_STR = "Add current selection to filter" def __init__(self, parent, show_empty=True): - """Init class. - + """ Args: - parent (QWidget) + parent (QWidget): parent widget + show_empty (bool): if True, adds an empty row to the end of the list """ super().__init__(parent) self._data = [] @@ -180,9 +178,13 @@ def get_not_selected(self): return self._data_set.difference(self._selected) def set_filter(self, filter_expression): - if filter_expression and (isinstance(filter_expression, str) and not filter_expression.isspace()): + filter_expression = filter_expression.strip() + if filter_expression: + try: + self._filter_expression = re.compile(filter_expression) + except re.error: + return self._action_rows[0] = self._SELECT_ALL_STR - self._filter_expression = re.compile(filter_expression) self._filter_index = [i for i, item in enumerate(self._data) if self.search_filter_expression(item)] self._selected_filtered = set(self._data[i] for i in self._filter_index) self._add_to_selection = False @@ -282,11 +284,13 @@ class LazyFilterCheckboxListModel(SimpleFilterCheckboxListModel): """Extends SimpleFilterCheckboxListModel to allow for lazy loading in synch with another model.""" def __init__(self, parent, db_mngr, db_maps, fetch_parent, show_empty=True): - """Init class. - + """ Args: - parent (SpineDBEditor) - fetch_parent (FetchParent) + parent (SpineDBEditor): parent widget + db_mngr (SpineDBManager): database manager + db_maps (Sequence of DatabaseMapping): database maps + fetch_parent (FetchParent): fetch parent + show_empty (bool): if True, show an empty row at the end of the list """ super().__init__(parent, show_empty=show_empty) self._db_mngr = db_mngr @@ -315,11 +319,11 @@ class DataToValueFilterCheckboxListModel(SimpleFilterCheckboxListModel): """Extends SimpleFilterCheckboxListModel to allow for translating internal data to a value for display role.""" def __init__(self, parent, data_to_value, show_empty=True): - """Init class. - + """ Args: - parent (SpineDBEditor) + parent (SpineDBEditor): parent widget data_to_value (method): a method to translate item data to a value for display role + show_empty (bool): if True, add an empty row to the end of the list """ super().__init__(parent, show_empty=show_empty) self.data_to_value = data_to_value diff --git a/spinetoolbox/mvcmodels/filter_execution_model.py b/spinetoolbox/mvcmodels/filter_execution_model.py index 27e22c968..d05ef20b5 100644 --- a/spinetoolbox/mvcmodels/filter_execution_model.py +++ b/spinetoolbox/mvcmodels/filter_execution_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains FilterExecutionModel. -""" - +"""Contains FilterExecutionModel.""" from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel diff --git a/spinetoolbox/mvcmodels/indexed_value_table_model.py b/spinetoolbox/mvcmodels/indexed_value_table_model.py index 95d0d6ac6..d7e7f6931 100644 --- a/spinetoolbox/mvcmodels/indexed_value_table_model.py +++ b/spinetoolbox/mvcmodels/indexed_value_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A model for indexed parameter values, used by the parameter_value editors. -""" - +"""A model for indexed parameter values, used by the parameter_value editors.""" from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt from PySide6.QtGui import QColor diff --git a/spinetoolbox/mvcmodels/map_model.py b/spinetoolbox/mvcmodels/map_model.py index 04437bc78..d5dc860c2 100644 --- a/spinetoolbox/mvcmodels/map_model.py +++ b/spinetoolbox/mvcmodels/map_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A model for maps, used by the parameter_value editors. -""" +"""A model for maps, used by the parameter_value editors.""" from copy import deepcopy from numbers import Number from itertools import takewhile @@ -184,7 +183,7 @@ def flags(self, index): def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): """Returns row numbers for vertical headers and column titles for horizontal ones.""" if role != Qt.ItemDataRole.DisplayRole: - return None + return super().headerData(section, orientation, role) if orientation == Qt.Orientation.Vertical: if section < len(self._rows): return section + 1 diff --git a/spinetoolbox/mvcmodels/minimal_table_model.py b/spinetoolbox/mvcmodels/minimal_table_model.py index 943525561..2a99e2b2e 100644 --- a/spinetoolbox/mvcmodels/minimal_table_model.py +++ b/spinetoolbox/mvcmodels/minimal_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,17 +10,15 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a minimal table model. -""" - +""" Contains a minimal table model. """ from PySide6.QtCore import Qt, QModelIndex, QAbstractTableModel class MinimalTableModel(QAbstractTableModel): - def __init__(self, parent=None, header=None, lazy=True): - """Table model for outlining simple tabular data. + """Table model for outlining simple tabular data.""" + def __init__(self, parent=None, header=None, lazy=True): + """ Args: parent (QObject, optional): the parent object header (list of str): header labels @@ -28,6 +27,7 @@ def __init__(self, parent=None, header=None, lazy=True): super().__init__(parent) if header is None: header = [] + self._parent = parent self.header = header self._main_data = list() self._fetched = not lazy diff --git a/spinetoolbox/mvcmodels/minimal_tree_model.py b/spinetoolbox/mvcmodels/minimal_tree_model.py index 690346868..14b4aee41 100644 --- a/spinetoolbox/mvcmodels/minimal_tree_model.py +++ b/spinetoolbox/mvcmodels/minimal_tree_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,19 +10,17 @@ # this program. If not, see . ###################################################################################################################### -""" -Models to represent items in a tree. -""" +"""Models to represent items in a tree.""" from PySide6.QtCore import Qt, QAbstractItemModel, QModelIndex class TreeItem: """A tree item that can fetch its children.""" - def __init__(self, model=None): + def __init__(self, model): """ Args: - model (MinimalTreeModel, optional): The model where the item belongs. + model (MinimalTreeModel): The model where the item belongs. """ super().__init__() self._children = [] @@ -29,10 +28,15 @@ def __init__(self, model=None): self._parent_item = None self._fetched = False self._set_up_once = False + self._has_children_initially = False + self._created_children = {} + + def set_has_children_initially(self, has_children_initially): + self._has_children_initially = has_children_initially def has_children(self): """Returns whether this item has or could have children.""" - if self.can_fetch_more(): + if self._has_children_initially: return True return bool(self.child_count()) @@ -40,11 +44,6 @@ def has_children(self): def model(self): return self._model - @property - def child_item_class(self): - """Returns the type of child items. Reimplement in subclasses to return something more meaningful.""" - return TreeItem - @property def children(self): return self._children @@ -67,30 +66,39 @@ def parent_item(self, parent_item): if not isinstance(parent_item, TreeItem) and parent_item is not None: raise ValueError("Parent must be instance of TreeItem or None") self._parent_item = parent_item - if parent_item is not None: - self._model = parent_item.model - self._model.destroyed.connect(lambda obj=None: self.tear_down()) + + def is_valid(self): + """Tests if item is valid. + + Return: + bool: True if item is valid, False otherwise + """ + return True def child(self, row): """Returns the child at given row or None if out of bounds.""" - try: + if 0 <= row < len(self._children): return self.children[row] - except IndexError: - return None + return None def last_child(self): """Returns the last child.""" - return self.child(-1) + return self.child(self.child_count() - 1) def child_count(self): """Returns the number of children.""" return len(self.children) + def row_count(self): + """Returns the number of rows, which may be different from the number of children. + This allows subclasses to hide children.""" + return self.child_count() + def child_number(self): """Returns the rank of this item within its parent or -1 if it's an orphan.""" if self.parent_item: return self.parent_item.children.index(self) - return -1 + return None def find_children(self, cond=lambda child: True): """Returns children that meet condition expressed as a lambda function.""" @@ -108,7 +116,7 @@ def next_sibling(self): def previous_sibling(self): """Returns the previous sibling or None if it's the first.""" - if self.child_number() <= 0: + if self.child_number() is None: return None return self.parent_item.child(self.child_number() - 1) @@ -123,6 +131,9 @@ def set_up(self): def _do_set_up(self): """Do stuff after the item has been inserted.""" + def _polish_children(self, children): + """Polishes children just before inserting them.""" + def insert_children(self, position, children): """Insert new children at given position. Returns a boolean depending on how it went. @@ -135,9 +146,10 @@ def insert_children(self, position, children): """ bad_types = [type(child) for child in children if not isinstance(child, TreeItem)] if bad_types: - raise TypeError(f"Can't insert children of type {bad_types} to an item of type {type(self)}") + raise TypeError(f"Can't insert children of type {bad_types} to an item of type {type(self).__name__}") if position < 0 or position > self.child_count(): return False + self._polish_children(children) parent_index = self.index() self.model.beginInsertRows(parent_index, position, position + len(children) - 1) for child in children: @@ -156,7 +168,9 @@ def tear_down(self): """Do stuff after the item has been removed.""" def tear_down_recursively(self): - for child in self.children: + for child in self._created_children.values(): + child.tear_down_recursively() + for child in self._children: child.tear_down_recursively() self.tear_down() @@ -176,20 +190,12 @@ def remove_children(self, position, count): return False if last >= self.child_count(): last = self.child_count() - 1 - children = self.children[first : last + 1] self.model.beginRemoveRows(self.index(), first, last) - for child in children: - child.parent_item = None del self.children[first : last + 1] self.model.endRemoveRows() - for child in children: - child.tear_down_recursively() + self._has_children_initially = False return True - def clear_children(self): - """Clear children list.""" - self.children.clear() - # pylint: disable=no-self-use def flags(self, column): """Enables the item and makes it selectable.""" @@ -287,7 +293,14 @@ def visit_all(self, index=QModelIndex(), view=None): current = parent_item def item_from_index(self, index): - """Return the item corresponding to the given index.""" + """Return the item corresponding to the given index. + + Args: + index (QModelIndex): model index + + Returns: + TreeItem: item at index + """ if index.isValid(): return index.internalPointer() return self._invisible_root_item @@ -302,7 +315,7 @@ def index_from_item(self, item): QModelIndex: item's index """ row = item.child_number() - if row < 0: + if row is None: return QModelIndex() return self.createIndex(row, 0, item) @@ -331,13 +344,15 @@ def rowCount(self, parent=QModelIndex()): if parent.column() > 0: return 0 parent_item = self.item_from_index(parent) - return parent_item.child_count() + return parent_item.row_count() def data(self, index, role=Qt.ItemDataRole.DisplayRole): """Returns the data stored under the given role for the index.""" if not index.isValid(): return None item = self.item_from_index(index) + if not item.is_valid(): + return None return item.data(index.column(), role) def setData(self, index, value, role=Qt.ItemDataRole.EditRole): diff --git a/spinetoolbox/mvcmodels/project_item_model.py b/spinetoolbox/mvcmodels/project_item_model.py deleted file mode 100644 index af2e50660..000000000 --- a/spinetoolbox/mvcmodels/project_item_model.py +++ /dev/null @@ -1,358 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Contains a class for storing project items. -""" -import logging -from copy import copy -from PySide6.QtCore import Qt, QModelIndex, QAbstractItemModel, Slot -from PySide6.QtGui import QIcon, QFont - -from .project_tree_item import LeafProjectTreeItem - - -class ProjectItemModel(QAbstractItemModel): - def __init__(self, root, parent=None): - """Class to store project tree items and ultimately project items in a tree structure. - - Args: - root (RootProjectTreeItem): Root item for the project item tree - parent (QObject): parent object - """ - super().__init__(parent) - self._root = root - self._project = None - - def root(self): - """Returns the root item.""" - return self._root - - def connect_to_project(self, project): - """Connects the model to a project. - - Args: - project (SpineToolboxProject): project to connect to - """ - self.remove_leaves() - self._project = project - project.project_about_to_be_torn_down.connect(self.remove_leaves) - project.item_added.connect(self._add_leaf_item) - project.item_about_to_be_removed.connect(self._remove_leaf_item) - project.item_renamed.connect(self._rename_item) - - @Slot(str) - def _add_leaf_item(self, name): - """Adds a leaf item to the model - - Args: - name (str): project item's name - """ - project_item = self._project.get_item(name) - leaf_item = LeafProjectTreeItem(project_item) - category_index = self.find_category(project_item.item_category()) - self.insert_item(leaf_item, category_index) - - @Slot(str) - def _remove_leaf_item(self, name): - """Removes a leaf item from the model. - - Args: - name (str): project item's name - """ - leaf_item = self.find_item(name).internalPointer() - project_item = leaf_item.project_item - category_index = self.find_category(project_item.item_category()) - self.remove_item(leaf_item, category_index) - - @Slot(str, str) - def _rename_item(self, old_name, new_name): - """Renames a leaf item. - - Args: - old_name (str): item's old name - new_name (str): item's new name - """ - self._remove_leaf_item(old_name) - self._add_leaf_item(new_name) - - def rowCount(self, parent=QModelIndex()): - """Reimplemented rowCount method. - - Args: - parent (QModelIndex): Index of parent item whose children are counted. - - Returns: - int: Number of children of given parent - """ - if not parent.isValid(): # Number of category items (children of root) - return self.root().child_count() - return parent.internalPointer().child_count() - - def columnCount(self, parent=QModelIndex()): - """Returns model column count which is always 1.""" - return 1 - - def flags(self, index): - """Returns flags for the item at given index - - Args: - index (QModelIndex): Flags of item at this index. - """ - return index.internalPointer().flags() - - def parent(self, index=QModelIndex()): - """Returns index of the parent of given index. - - Args: - index (QModelIndex): Index of item whose parent is returned - - Returns: - QModelIndex: Index of parent item - """ - item = self.item(index) - parent_item = item.parent() - if not parent_item: - return QModelIndex() - if parent_item == self.root(): - return QModelIndex() - # logging.debug("parent_item: {0}".format(parent_item.name)) - return self.createIndex(parent_item.row(), 0, parent_item) - - def index(self, row, column, parent=QModelIndex()): - """Returns index of item with given row, column, and parent. - - Args: - row (int): Item row - column (int): Item column - parent (QModelIndex): Parent item index - - Returns: - QModelIndex: Item index - """ - if row < 0 or row >= self.rowCount(parent): - return QModelIndex() - if column < 0 or column >= self.columnCount(parent): - return QModelIndex() - parent_item = self.item(parent) - child = parent_item.child(row) - if not child: - return QModelIndex() - return self.createIndex(row, column, child) - - def data(self, index, role=None): - """Returns data in the given index according to requested role. - - Args: - index (QModelIndex): Index to query - role (int): Role to return - - Returns: - object: Data depending on role. - """ - if not index.isValid(): - return None - item = index.internalPointer() - if role == Qt.ItemDataRole.DisplayRole: - return item.name - if role == Qt.ItemDataRole.DecorationRole: - if not hasattr(item, "project_item"): - # item is a CategoryProjectTreeItem or root - return None - # item is a LeafProjectTreeItem - icon_path = item.project_item.get_icon().icon_file - return QIcon(icon_path) - if role == Qt.ItemDataRole.FontRole: - if not hasattr(item, "project_item"): - bold_font = QFont() - bold_font.setBold(True) - return bold_font - return None - - def item(self, index): - """Returns item at given index. - - Args: - index (QModelIndex): Index of item - - Returns: - RootProjectTreeItem, CategoryProjectTreeItem or LeafProjectTreeItem: Item at given index or root project - item if index is not valid - """ - if not index.isValid(): - return self.root() - return index.internalPointer() - - def find_category(self, category_name): - """Returns the index of the given category name. - - Args: - category_name (str): Name of category item to find - - Returns: - QModelIndex: index of a category item or None if it was not found - """ - category_names = [category.name for category in self.root().children()] - try: - row = category_names.index(category_name) - except ValueError: - logging.error("Category name %s not found in %s", category_name, category_names) - return None - return self.index(row, 0, QModelIndex()) - - def find_item(self, name): - """Returns the QModelIndex of the leaf item with the given name - - Args: - name (str): The searched project item (long) name - - Returns: - QModelIndex: Index of a project item with the given name or None if not found - """ - for category in self.root().children(): - category_index = self.find_category(category.name) - start_index = self.index(0, 0, category_index) - matching_index = self.match( - start_index, Qt.ItemDataRole.DisplayRole, name, 1, Qt.MatchFixedString | Qt.MatchRecursive - ) - if not matching_index: - pass # no match in this category - elif len(matching_index) == 1: - return matching_index[0] - return None - - def get_item(self, name): - """Returns leaf item with given name or None if it doesn't exist. - - Args: - name (str): Project item name - - Returns: - LeafProjectTreeItem, NoneType - """ - ind = self.find_item(name) - if ind is None: - return None - return self.item(ind) - - def category_of_item(self, name): - """Returns the category item of the category that contains project item with given name - - Args: - name (str): Project item name - - Returns: - CategoryProjectTreeItem: category item or None if the category was not found - """ - for category in self.root().children(): - for item in category.children(): - if name == item.name: - return category - return None - - def insert_item(self, item, parent=QModelIndex()): - """Adds a new item to model. Fails if given parent is not - a category item nor a leaf item. New item is inserted as - the last item of its branch. - - Args: - item (CategoryProjectTreeItem or LeafProjectTreeItem): Project item to add to model - parent (QModelIndex): Parent project item - - Returns: - bool: True if successful, False otherwise - """ - parent_item = self.item(parent) - row = self.rowCount(parent) # parent.child_count() - self.beginInsertRows(parent, row, row) - retval = parent_item.add_child(item) - self.endInsertRows() - return retval - - def remove_item(self, item, parent=QModelIndex()): - """Removes item from project. - - Args: - item (BaseProjectTreeItem): Item to remove - parent (QModelIndex): Parent of item that is to be removed - - Returns: - bool: True if item removed successfully, False if item removing failed - """ - parent_item = self.item(parent) - row = item.row() - self.beginRemoveRows(parent, row, row) - parent_item.remove_child(row) - self.endRemoveRows() - - def items(self, category_name=None): - """Returns a list of leaf items in model according to category name. If no category name given, - returns all leaf items in a list. - - Args: - category_name (str): Item category. Data Connections, Data Stores, Importers, Exporters, Tools or Views - permitted. - - Returns: - :obj:'list' of :obj:'LeafProjectTreeItem': Depending on category_name argument, returns all items or only - items according to category. An empty list is returned if there are no items in the given category - or if an unknown category name was given. - """ - if not category_name: - items = list() - for category in self.root().children(): - items += category.children() - return items - category_index = self.find_category(category_name) - if not category_index: - logging.error("Category item '%s' not found", category_name) - return list() - return category_index.internalPointer().children() - - def n_items(self): - """Returns the number of all items in the model excluding category items and root. - - Returns: - int: Number of items - """ - return len(self.items()) - - def item_names(self): - """Returns all leaf item names in a list. - - Returns: - obj:'list' of obj:'str': Item names - """ - return [item.name for item in self.items()] - - def items_per_category(self): - """Returns a dict mapping category indexes to a list of items in that category. - - Returns: - dict(QModelIndex,list(LeafProjectTreeItem)) - """ - category_inds = [self.index(row, 0) for row in range(self.rowCount())] - return {ind: copy(ind.internalPointer().children()) for ind in category_inds} - - def leaf_indexes(self): - """Yields leaf indexes.""" - for row in range(self.rowCount()): - category_index = self.index(row, 0) - for inner_row in range(self.rowCount(category_index)): - yield self.index(inner_row, 0, category_index) - - @Slot() - def remove_leaves(self): - self.beginResetModel() - for row in range(self.rowCount()): - category_index = self.index(row, 0) - category_index.internalPointer().children().clear() - self.endResetModel() diff --git a/spinetoolbox/mvcmodels/project_item_specification_models.py b/spinetoolbox/mvcmodels/project_item_specification_models.py index 7b3914a36..6813739a6 100644 --- a/spinetoolbox/mvcmodels/project_item_specification_models.py +++ b/spinetoolbox/mvcmodels/project_item_specification_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a class for storing Tool specifications. -""" - +"""Contains a class for storing Tool specifications.""" import bisect from PySide6.QtCore import Qt, QModelIndex, QAbstractListModel, QSortFilterProxyModel, Slot, Signal @@ -107,13 +105,6 @@ def data(self, index, role=None): row = index.row() if role == Qt.ItemDataRole.DisplayRole: return self._spec_names[row] - if role == Qt.ItemDataRole.ToolTipRole: - if row >= self.rowCount(): - return "" - return ( - "

Drag-and-drop this onto the Design View " - f"to create a new {self._spec_names[row]} item.

" - ) if role == Qt.ItemDataRole.DecorationRole: spec = self.specification(row) return self._icons[spec.item_type] diff --git a/spinetoolbox/mvcmodels/project_tree_item.py b/spinetoolbox/mvcmodels/project_tree_item.py deleted file mode 100644 index 939869715..000000000 --- a/spinetoolbox/mvcmodels/project_tree_item.py +++ /dev/null @@ -1,203 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Project Tree items. -""" - -import logging -import bisect -from PySide6.QtCore import Qt -from spinetoolbox.metaobject import MetaObject - - -class BaseProjectTreeItem(MetaObject): - """Base class for all project tree items.""" - - def __init__(self, name, description): - """ - Args: - name (str): Object name - description (str): Object description - """ - super().__init__(name, description) - self._parent = None # Parent BaseProjectTreeItem. Set when add_child is called - self._children = list() # Child BaseProjectTreeItems. Appended when new items are inserted into model. - - def flags(self): # pylint: disable=no-self-use - """Returns the item flags.""" - return Qt.NoItemFlags - - def parent(self): - """Returns parent project tree item.""" - return self._parent - - def child_count(self): - """Returns the number of child project tree items.""" - return len(self._children) - - def children(self): - """Returns the children of this project tree item.""" - return self._children - - def child(self, row): - """Returns child BaseProjectTreeItem on given row. - - Args: - row (int): Row of child to return - - Returns: - BaseProjectTreeItem: item on given row or None if it does not exist - """ - try: - item = self._children[row] - except IndexError: - logging.error("[%s] has no child on row %s", self.name, row) - return None - return item - - def row(self): - """Returns the row on which this item is located.""" - if self._parent is not None: - r = self._parent.children().index(self) - # logging.debug("{0} is on row:{1}".format(self.name, r)) - return r - return 0 - - def add_child(self, child_item): - """Base method that shall be overridden in subclasses.""" - raise NotImplementedError() - - def remove_child(self, row): - """Remove the child of this BaseProjectTreeItem from given row. Do not call this method directly. - This method is called by ProjectItemTreeModel when items are removed. - - Args: - row (int): Row of child to remove - - Returns: - bool: True if operation succeeded, False otherwise - """ - if row < 0 or row > len(self._children): - return False - child = self._children.pop(row) - child._parent = None - return True - - def custom_context_menu(self, toolbox): - """Returns the context menu for this item. Implement in subclasses as needed. - - Args: - toolbox (QWidget): The widget that is controlling the menu - - Returns: - QMenu: context menu - """ - raise NotImplementedError() - - -class RootProjectTreeItem(BaseProjectTreeItem): - """Class for the root project tree item.""" - - def __init__(self): - super().__init__("root", "The Root Project Tree Item.") - - def add_child(self, child_item): - """Adds given category item as the child of this root project tree item. New item is added as the last item. - - Args: - child_item (CategoryProjectTreeItem): Item to add - - Returns: - True for success, False otherwise - """ - if isinstance(child_item, CategoryProjectTreeItem): - self._children.append(child_item) - child_item._parent = self - return True - logging.error("You can only add a category item as a child of the root item") - return False - - def custom_context_menu(self, toolbox): - """See base class.""" - raise NotImplementedError() - - -class CategoryProjectTreeItem(BaseProjectTreeItem): - """Class for category project tree items.""" - - def flags(self): - """Returns the item flags.""" - return Qt.ItemIsEnabled - - def add_child(self, child_item): - """Adds given project tree item as the child of this category item. New item is added as the last item. - - Args: - child_item (LeafProjectTreeTreeItem): Item to add - Returns: - True for success, False otherwise - """ - if not isinstance(child_item, LeafProjectTreeItem): - logging.error("You can only add a leaf item as a child of a category item") - return False - key = lambda x: x.name.lower() - pos = bisect.bisect_left([key(x) for x in self._children], key(child_item)) - self._children.insert(pos, child_item) - child_item._parent = self - return True - - def custom_context_menu(self, toolbox): - """Returns the context menu for this item. - - Args: - toolbox (ToolboxUI): Toolbox main window - - Returns: - QMenu: context menu - """ - return toolbox.item_category_context_menu() - - -class LeafProjectTreeItem(BaseProjectTreeItem): - """Class for leaf items in the project item tree.""" - - def __init__(self, project_item): - """ - Args: - project_item (ProjectItem): the real project item this item represents - """ - super().__init__(project_item.name, project_item.description) - self._project_item = project_item - - @property - def project_item(self): - """the project item linked to this leaf""" - return self._project_item - - def add_child(self, child_item): - """See base class.""" - raise NotImplementedError() - - def flags(self): - """Returns the item flags.""" - return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable - - def custom_context_menu(self, toolbox): - """Returns the context menu for this item. - - Args: - toolbox (ToolboxUI): Toolbox main window - - Returns: - QMenu: context menu - """ - return toolbox.project_item_context_menu(self._project_item.actions()) diff --git a/spinetoolbox/mvcmodels/resource_filter_model.py b/spinetoolbox/mvcmodels/resource_filter_model.py index cb6de2997..d3fecb438 100644 --- a/spinetoolbox/mvcmodels/resource_filter_model.py +++ b/spinetoolbox/mvcmodels/resource_filter_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,21 +10,19 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains ResourceFilterModel. -""" +""" Contains ResourceFilterModel. """ from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QStandardItemModel, QStandardItem +from spinedb_api.filters.alternative_filter import ALTERNATIVE_FILTER_TYPE from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE -from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE from ..project_commands import SetFiltersOnlineCommand class ResourceFilterModel(QStandardItemModel): tree_built = Signal() _SELECT_ALL = "Select all" - _FILTER_TYPES = {"Scenario filter": SCENARIO_FILTER_TYPE, "Tool filter": TOOL_FILTER_TYPE} - _FILTER_TYPE_TO_TEXT = dict(zip(_FILTER_TYPES.values(), _FILTER_TYPES.keys())) + FILTER_TYPES = {"Scenario filter": SCENARIO_FILTER_TYPE, "Alternative filter": ALTERNATIVE_FILTER_TYPE} + FILTER_TYPE_TO_TEXT = dict(zip(FILTER_TYPES.values(), FILTER_TYPES.keys())) def __init__(self, connection, project, undo_stack, logger): """ @@ -46,13 +45,14 @@ def connection(self): def build_tree(self): """Rebuilds model's contents.""" - def append_filter_items(parent_item, filter_names, filter_type, online, online_default): + def append_filter_items(parent_item, filter_names, filter_type, online, online_default, enabled): for name in filter_names[filter_type]: filter_item = QStandardItem(name) filter_item.setCheckState( Qt.CheckState.Checked if online.get(name, online_default) else Qt.CheckState.Unchecked ) - filter_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) + filter_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) + filter_item.setEnabled(enabled) parent_item.appendRow(filter_item) self.clear() @@ -62,7 +62,7 @@ def append_filter_items(parent_item, filter_names, filter_type, online, online_d root_item = QStandardItem(resource_label) root_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) self.appendRow(root_item) - for type_label, type_ in self._FILTER_TYPES.items(): + for type_label, type_ in self.FILTER_TYPES.items(): filter_parent = QStandardItem(type_label) if not filters_by_type.get(type_): no_filters_item = QStandardItem("None available") @@ -70,15 +70,23 @@ def append_filter_items(parent_item, filter_names, filter_type, online, online_d filter_parent.appendRow(no_filters_item) root_item.appendRow(filter_parent) continue - filter_parent.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + filter_enabled = self._connection.is_filter_type_enabled(type_) + filter_parent.setFlags(Qt.ItemIsSelectable) + filter_parent.setEnabled(filter_enabled) select_all_item = QStandardItem(self._SELECT_ALL) - select_all_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) + select_all_item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable) select_all_item.setCheckState(Qt.CheckState.Unchecked) + select_all_item.setEnabled(filter_enabled) filter_parent.appendRow(select_all_item) root_item.appendRow(filter_parent) online_filters = self._connection.online_filters(resource_label, type_) append_filter_items( - filter_parent, filters_by_type, type_, online_filters, self._connection.is_filter_online_by_default + filter_parent, + filters_by_type, + type_, + online_filters, + self._connection.is_filter_online_by_default, + filter_enabled, ) self._set_all_selected_item(resource_label, filter_parent) self.tree_built.emit() @@ -89,12 +97,10 @@ def fetch_filters(self): url = resource.url if not url: continue - scenario_names = self._connection.get_scenario_names(url) - if scenario_names: - filters.setdefault(resource.label, {})[SCENARIO_FILTER_TYPE] = scenario_names - tool_names = self._connection.get_tool_names(url) - if tool_names: - filters.setdefault(resource.label, {})[TOOL_FILTER_TYPE] = tool_names + for filter_type in (SCENARIO_FILTER_TYPE, ALTERNATIVE_FILTER_TYPE): + names = self._connection.get_filter_item_names(filter_type, url) + if names: + filters.setdefault(resource.label, {})[filter_type] = names return filters def setData(self, index, value, role=Qt.ItemDataRole.EditRole): @@ -114,7 +120,7 @@ def _change_filter_checked_state(self, index, is_on): if item.hasChildren(): return resource_type_item = item.parent() - filter_type = self._FILTER_TYPES[resource_type_item.text()] + filter_type = self.FILTER_TYPES[resource_type_item.text()] root_item = resource_type_item.parent() resource_label = root_item.text() if item.text() == self._SELECT_ALL: @@ -131,7 +137,7 @@ def set_online(self, resource, filter_type, online): Args: resource (str): Resource label - filter_type (str): Either SCENARIO_FILTER_TYPE or TOOL_FILTER_TYPE, for now. + filter_type (str): Always SCENARIO_FILTER_TYPE, for now. online (dict): mapping from scenario/tool id to online flag """ self.connection.set_online(resource, filter_type, online) @@ -159,13 +165,31 @@ def _find_filter_type_item(self, resource, filter_type): """ root_item = self.findItems(resource)[0] filter_type_item = None - filter_type_text = self._FILTER_TYPE_TO_TEXT[filter_type] + filter_type_text = self.FILTER_TYPE_TO_TEXT[filter_type] for row in range(root_item.rowCount()): filter_type_item = root_item.child(row) if filter_type_item.text() == filter_type_text: break return filter_type_item + def filter_type_items(self, filter_type): + """An iterator to filter type items. + + Args: + filter_type (str): filter type + + Yields: + QStandardItem: filter type item + """ + filter_text = self.FILTER_TYPE_TO_TEXT[filter_type] + root_item = self.invisibleRootItem() + for root_row in range(root_item.rowCount()): + resource_item = root_item.child(root_row) + for resource_row in range(resource_item.rowCount()): + filter_type_item = resource_item.child(resource_row) + if filter_type_item.text() == filter_text: + yield filter_type_item + def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=False): """Updates 'Select All' item's checked state. @@ -174,7 +198,7 @@ def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=F filter_type_item (QStandardItem): filter type item emit_data_changed (bool): if True, emit dataChanged signal if the state was updated """ - online_filters = self._connection.online_filters(resource, self._FILTER_TYPES[filter_type_item.text()]) + online_filters = self._connection.online_filters(resource, self.FILTER_TYPES[filter_type_item.text()]) all_online = all(online_filters.values()) all_selected_item = filter_type_item.child(0) all_selected = all_selected_item.data(Qt.ItemDataRole.CheckStateRole) == Qt.CheckState.Checked.value @@ -185,3 +209,23 @@ def _set_all_selected_item(self, resource, filter_type_item, emit_data_changed=F self.dataChanged.emit( all_selected_item.index(), all_selected_item.index(), [Qt.ItemDataRole.CheckStateRole] ) + + def set_filter_type_enabled(self, filter_type, enabled): + """Enables or disables a filter type. + + Args: + filter_type (str): filter type + enabled (bool): whether the filter is enabled + """ + filter_text = self.FILTER_TYPE_TO_TEXT[filter_type] + root_item = self.invisibleRootItem() + for root_row in range(root_item.rowCount()): + resource_item = root_item.child(root_row) + for resource_row in range(resource_item.rowCount()): + filter_type_item = resource_item.child(resource_row) + if filter_type_item.text() != filter_text: + continue + filter_type_item.setEnabled(enabled) + for filter_type_row in range(filter_type_item.rowCount()): + filter_item = filter_type_item.child(filter_type_row) + filter_item.setEnabled(enabled) diff --git a/spinetoolbox/mvcmodels/shared.py b/spinetoolbox/mvcmodels/shared.py index 868353e84..487754ab3 100644 --- a/spinetoolbox/mvcmodels/shared.py +++ b/spinetoolbox/mvcmodels/shared.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains stuff that is used by more than one model -""" - +"""Contains stuff that is used by more than one model.""" from PySide6.QtCore import Qt PARSED_ROLE = Qt.ItemDataRole.UserRole diff --git a/spinetoolbox/mvcmodels/time_pattern_model.py b/spinetoolbox/mvcmodels/time_pattern_model.py index e69a92580..652afaffd 100644 --- a/spinetoolbox/mvcmodels/time_pattern_model.py +++ b/spinetoolbox/mvcmodels/time_pattern_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A model for time patterns, used by the parameter_value editors. -""" - +"""A model for time patterns, used by the parameter_value editors.""" import numpy as np from PySide6.QtCore import QModelIndex, Qt from PySide6.QtWidgets import QMessageBox diff --git a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py index efdc727f7..339c3c77b 100644 --- a/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py +++ b/spinetoolbox/mvcmodels/time_series_model_fixed_resolution.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A model for fixed resolution time series, used by the parameter_value editors. -""" - +"""A model for fixed resolution time series, used by the parameter_value editors.""" import numpy as np from PySide6.QtCore import QModelIndex, Qt, Slot, QLocale from spinedb_api import TimeSeriesFixedResolution diff --git a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py index 2c977fcb6..683aeb799 100644 --- a/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py +++ b/spinetoolbox/mvcmodels/time_series_model_variable_resolution.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -A model for variable resolution time series, used by the parameter_value editors. -""" - +"""A model for variable resolution time series, used by the parameter_value editors.""" import numpy as np - from PySide6.QtCore import QModelIndex, Qt, Slot from spinedb_api import TimeSeriesVariableResolution from .indexed_value_table_model import IndexedValueTableModel @@ -63,7 +60,7 @@ def insertRows(self, row, count, parent=QModelIndex()): if len(old_indexes) > 1: last_time_step = last_time_stamp - old_indexes[-2] else: - last_time_step = np.timedelta64(1, 'h') + last_time_step = np.timedelta64(1, "h") new_indexes[: len(old_indexes)] = old_indexes for i in range(count): @@ -78,7 +75,7 @@ def insertRows(self, row, count, parent=QModelIndex()): if len(old_indexes) > 1: time_step = old_indexes[1] - first_time_stamp else: - time_step = np.timedelta64(1, 'h') + time_step = np.timedelta64(1, "h") for i in range(count): new_indexes[i] = first_time_stamp - (count - i) * time_step new_indexes[count:] = old_indexes diff --git a/spinetoolbox/plotting.py b/spinetoolbox/plotting.py index 0c54f0a47..83562b8dc 100644 --- a/spinetoolbox/plotting.py +++ b/spinetoolbox/plotting.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Functions for plotting on PlotWidget. -""" +"""Functions for plotting on PlotWidget.""" import datetime from enum import auto, Enum, unique import math @@ -20,14 +19,12 @@ import functools from operator import methodcaller, itemgetter from typing import Dict, List, Optional, Union - from matplotlib.patches import Patch from matplotlib.ticker import MaxNLocator import numpy as np -from PySide6.QtCore import Qt, QModelIndex - +from PySide6.QtCore import Qt from spinedb_api.parameter_value import NUMPY_DATETIME64_UNIT, from_database -from spinedb_api import Array, IndexedValue, TimeSeries, DateTime +from spinedb_api import IndexedValue, DateTime from .mvcmodels.shared import PARSED_ROLE from .widgets.plot_canvas import LegendPosition from .widgets.plot_widget import PlotWidget @@ -299,7 +296,7 @@ def plot_data(data_list, plot_widget=None, plot_type=None): plot_widget.canvas.axes.set_title(plot_title) for data in data_list: if type(data.x[0]) not in (float, np.float_, int): - plot_widget.canvas.axes.tick_params(axis='x', labelrotation=30) + plot_widget.canvas.axes.tick_params(axis="x", labelrotation=30) if len(squeezed_data) > 1: plot_widget.add_legend(legend_handles) if needs_redraw: @@ -701,7 +698,7 @@ def plot_db_mngr_items(items, db_maps, plot_widget=None): Args: items (list of dict): parameter value items - db_maps (list of DatabaseMappingBase): database mappings corresponding to items + db_maps (list of DatabaseMapping): database mappings corresponding to items plot_widget (PlotWidget, optional): widget to add plots to """ if not items: @@ -718,14 +715,12 @@ def plot_db_mngr_items(items, db_maps, plot_widget=None): except PlottingError as error: raise PlottingError(f"Failed to plot value in {db_map.codename}: {error}") db_name = db_map.codename - parameter_name = item["parameter_name"] - object_name_list = item["object_name_list"] - if object_name_list is not None: - object_names = tuple(object_name_list.split(",")) - else: - object_names = (item["object_name"],) + parameter_name = item["parameter_definition_name"] + entity_byname = item["entity_byname"] + if not isinstance(entity_byname, tuple): + entity_byname = (entity_byname,) alternative_name = item["alternative_name"] - indexes = (db_name, parameter_name) + object_names + (alternative_name,) + indexes = (db_name, parameter_name) + entity_byname + (alternative_name,) index_names = _pivot_index_names(indexes) node = root_node for i, index in enumerate(indexes[:-1]): diff --git a/spinetoolbox/plugin_manager.py b/spinetoolbox/plugin_manager.py index cb3da5048..c964e1093 100644 --- a/spinetoolbox/plugin_manager.py +++ b/spinetoolbox/plugin_manager.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains PluginManager class. -""" +"""Contains PluginManager class.""" import itertools import os import json @@ -44,7 +43,7 @@ def _download_file(remote, local): def _download_plugin(plugin, plugin_local_dir): # 1. Create paths plugin_remote_file = plugin["url"] - plugin_remote_dir = urljoin(plugin_remote_file, '.') + plugin_remote_dir = urljoin(plugin_remote_file, ".") plugin_local_file = os.path.join(plugin_local_dir, "plugin.json") # 2. Download and parse plugin.json file _download_file(plugin_remote_file, plugin_local_file) @@ -105,7 +104,6 @@ def load_installed_plugins(self): local_data = load_specification_local_data(project.config_dir) if project else {} for plugin_dir in plugins_dirs(self._toolbox.qsettings()): self.load_individual_plugin(plugin_dir, local_data) - self._toolbox.refresh_toolbars() def reload_plugins_with_local_data(self): """Reloads plugins that have project specific local data.""" @@ -153,7 +151,6 @@ def load_individual_plugin(self, plugin_dir, specification_local_data): self._plugin_specs.update(plugin_specs) toolbar = self._plugin_toolbars[name] = PluginToolBar(name, parent=self._toolbox) toolbar.setup(plugin_specs, disabled_plugins) - self._toolbox.addToolBar(Qt.TopToolBarArea, toolbar) def _create_worker(self): worker = _PluginWorker() diff --git a/spinetoolbox/project.py b/spinetoolbox/project.py index 399ccdc33..b3187f113 100644 --- a/spinetoolbox/project.py +++ b/spinetoolbox/project.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,17 +10,15 @@ # this program. If not, see . ###################################################################################################################### -""" -Spine Toolbox project class. -""" +"""Spine Toolbox project class.""" from enum import auto, Enum, unique from itertools import chain import os from pathlib import Path import json -import random from PySide6.QtCore import Signal, QCoreApplication from PySide6.QtGui import QColor +from PySide6.QtWidgets import QMessageBox import networkx as nx from spine_engine.exception import EngineInitFailed, RemoteEngineInitFailed from spine_engine.utils.helpers import create_timestamp, gather_leaf_data @@ -153,10 +152,52 @@ def toolbox(self): def all_item_names(self): return list(self._project_items) + @property + def n_items(self): + return len(self.all_item_names) + @property def settings(self): return self._settings + def has_items(self): + """Returns True if project has project items. + + Returns: + bool: True if project has items, False otherwise + """ + return bool(self._project_items) + + def get_item(self, name): + """Returns project item. + + Args: + name (str): Item's name + + Returns: + ProjectItem: Project item + """ + return self._project_items[name] + + def get_items(self): + """Returns all project items. + + Returns: + list of ProjectItem: All project items + """ + return list(self._project_items.values()) + + def get_items_by_type(self, _type): + """Returns all project items with given _type. + + Args: + _type (str): Project Item type + + Returns: + list of ProjectItem: Project Items with given type or an empty list if none found + """ + return [item for item in self.get_items() if item.item_type() == _type] + def _create_project_structure(self, directory): """Makes the given directory a Spine Toolbox project directory. Creates directories and files that are common to all projects. @@ -284,6 +325,7 @@ def load(self, spec_factories, item_factories): Returns: bool: True if the operation was successful, False otherwise """ + self._toolbox.ui.textBrowser_eventlog.clear() project_dict = load_project_dict(self.config_dir, self._logger) if project_dict is None: return False @@ -315,7 +357,7 @@ def load(self, spec_factories, item_factories): self._logger.msg.emit("Loading project items...") if not items_dict: self._logger.msg_warning.emit("Project has no items") - self.restore_project_items(items_dict, item_factories, silent=True) + self.restore_project_items(items_dict, item_factories) self._logger.msg.emit("Restoring connections...") connection_dicts = project_info["project"]["connections"] connections = list(map(self.connection_from_dict, connection_dicts)) @@ -574,12 +616,11 @@ def _default_specification_file_path(self, specification): return None return candidate_path - def add_item(self, item, silent=True): - """Adds a project to item project. + def add_item(self, item): + """Adds a project item to project. Args: item (ProjectItem): item to add - silent (bool): if True, don't log messages """ if item.name in self._project_items: raise RuntimeError("Item already in project.") @@ -587,35 +628,6 @@ def add_item(self, item, silent=True): name = item.name self.item_added.emit(name) item.set_up() - if not silent: - self._logger.msg.emit(f"{item.item_type()} {name} added to project") - - def has_items(self): - """Returns True if project has project items. - - Returns: - bool: True if project has items, False otherwise - """ - return bool(self._project_items) - - def get_item(self, name): - """Returns project item. - - Args: - name (str): item's name - - Returns: - ProjectItem: project item - """ - return self._project_items[name] - - def get_items(self): - """Returns all project items. - - Returns: - list of ProjectItem: all project items - """ - return list(self._project_items.values()) def rename_item(self, previous_name, new_name, rename_data_dir_message): """Renames a project item @@ -746,7 +758,24 @@ def add_connection(self, *args, silent=False, notify_resource_changes=True): self.connection_established.emit(connection) self._update_jump_icons() if not self._is_dag_valid(dag): - return True # Connection was added successfully even though DAG is not valid. + self.remove_connection(connection) + msg = "This connection creates a cycle into the DAG.\n\nWould you like to add a Loop connection?" + title = f"Add Loop?" + message_box = QMessageBox( + QMessageBox.Icon.Question, + title, + msg, + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + parent=self._toolbox, + ) + message_box.button(QMessageBox.StandardButton.Ok).setText("Add Loop") + answer = message_box.exec() + if answer == QMessageBox.StandardButton.Cancel: + return False + src_conn = self.get_item(connection.source).get_icon().conn_button(connection.source_position) + dst_conn = self.get_item(connection.destination).get_icon().conn_button(connection.destination_position) + self._toolbox.ui.graphicsView.add_jump(src_conn, dst_conn) + return False destination = self._project_items[connection.destination] source = self._project_items[connection.source] if notify_resource_changes: @@ -906,13 +935,12 @@ def dag_with_node(self, node): """Returns the DiGraph that contains the given node (project item) name (str).""" return next((x for x in self._dag_iterator() if x.has_node(node)), None) - def restore_project_items(self, items_dict, item_factories, silent): + def restore_project_items(self, items_dict, item_factories): """Restores project items from dictionary. Args: items_dict (dict): a mapping from item name to item dict item_factories (dict): a mapping from item type to ProjectItemFactory - silent (bool): if True, suppress a log messages """ for item_name, item_dict in items_dict.items(): try: @@ -940,7 +968,7 @@ def restore_project_items(self, items_dict, item_factories, silent): ) continue project_item.copy_local_data(item_dict) - self.add_item(project_item, silent) + self.add_item(project_item) def remove_item_by_name(self, item_name, delete_data=False): """Removes project item by its name. @@ -1013,7 +1041,7 @@ def _execute_dags(self, dags, execution_permits_list): worker.finished.connect(lambda worker=worker: self._handle_engine_worker_finished(worker)) self._engine_workers.append(worker) timestamp = create_timestamp() - self._toolbox.start_execution(timestamp) + self._toolbox.make_execution_timestamp(timestamp) # NOTE: Don't start the workers as they are created. They may finish too quickly, before the others # are added to ``_engine_workers``, and thus ``_handle_engine_worker_finished()`` will believe # that the project is done executing before it's fully loaded. @@ -1106,12 +1134,12 @@ def execute_selected(self, names): for dag in [dag for dag in self._dag_iterator() if set(names) & dag.nodes]: more_dags = self._split_to_subdags(dag, names) dags += more_dags + valid_dags = self._validate_dags(dags) execution_permit_list = list() - for dag in dags: + for dag in valid_dags: execution_permits = {name: name in names for name in dag.nodes} execution_permit_list.append(execution_permits) - self._validate_dags(dags) - self.execute_dags(dags, execution_permit_list, "Executing Selected Directed Acyclic Graphs") + self.execute_dags(valid_dags, execution_permit_list, "Executing Selected Directed Acyclic Graphs") def _split_to_subdags(self, dag, selected_items): """Checks if given dag contains weakly connected components. If it does, @@ -1127,8 +1155,13 @@ def _split_to_subdags(self, dag, selected_items): """ if len(dag.nodes) == 1: return [dag] + # Get selected items that are in current dag + selected_items_in_this_dag = list() + for selected_item in list(selected_items): + if selected_item in list(dag.nodes()): + selected_items_in_this_dag.append(selected_item) # List of Connections that have a selected item as its source or destination item - connections = connections_to_selected_items(self._connections, set(selected_items)) + connections = connections_to_selected_items(self._connections, set(selected_items_in_this_dag)) edges = dag_edges(connections) d = make_dag(edges) # Make DAG as SpineEngine does it if nx.number_weakly_connected_components(d) > 1: @@ -1412,12 +1445,7 @@ def _update_predecessor(self, predecessor, outgoing_connections, resource_cache) def _is_dag_valid(self, dag): if not nx.is_directed_acyclic_graph(dag): - edges = _edges_causing_loops(dag) - for node in dag.nodes: - self._project_items[node].invalidate_workflow(edges) return False - for node in dag.nodes: - self._project_items[node].revalidate_workflow() return True def _update_ranks(self, dag): @@ -1488,7 +1516,8 @@ def prepare_remote_execution(self): return job_id def finalize_remote_execution(self, job_id): - """Sends a request to server to remove the project directory and removes the project ZIP file from client.y + """Sends a request to server to remove the project directory. In addition, + removes the project ZIP file from client machine. Args: job_id (str): job id @@ -1539,31 +1568,8 @@ def node_successors(g): return {n: list(g.successors(n)) for n in nx.topological_sort(g)} -def _edges_causing_loops(g): - """Returns a list of edges whose removal from g results in it becoming acyclic. - - Args: - g (DiGraph) - - Returns: - list - """ - result = list() - h = g.copy() # Let's work on a copy of the graph - while True: - try: - cycle = list(nx.find_cycle(h)) - except nx.NetworkXNoCycle: - break - edge = random.choice(cycle) - h.remove_edge(*edge) - result.append(edge) - return result - - def _ranks(node_successors): - """ - Calculates node ranks. + """Calculates node ranks. Args: node_successors (dict): a mapping from successor name to a list of predecessor names diff --git a/spinetoolbox/project_commands.py b/spinetoolbox/project_commands.py index fb83e802f..d6f818797 100644 --- a/spinetoolbox/project_commands.py +++ b/spinetoolbox/project_commands.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -QUndoCommand subclasses for modifying the project. -""" - +"""QUndoCommand subclasses for modifying the project.""" from PySide6.QtGui import QUndoCommand from spine_engine.project_item.connection import Jump @@ -32,31 +30,37 @@ def is_critical(self): class SetItemSpecificationCommand(SpineToolboxCommand): - def __init__(self, item, spec, old_spec): - """Command to set the specification for a Tool. + """Command to set the specification for a project item.""" + def __init__(self, item_name, spec, old_spec, project): + """ Args: - item (ProjectItem): the Item + item_name (str): item's name spec (ProjectItemSpecification): the new spec old_spec (ProjectItemSpecification): the old spec + project (SpineToolboxProject): project """ super().__init__() - self.item = item - self.spec = spec - self.old_spec = old_spec - self.setText(f"set specification of {item.name}") + self._item_name = item_name + self._spec = spec + self._old_spec = old_spec + self._project = project + self.setText(f"set specification of {item_name}") def redo(self): - self.item.do_set_specification(self.spec) + item = self._project.get_item(self._item_name) + item.do_set_specification(self._spec) def undo(self): - self.item.do_set_specification(self.old_spec) + item = self._project.get_item(self._item_name) + item.do_set_specification(self._old_spec) class MoveIconCommand(SpineToolboxCommand): - def __init__(self, icon, project): - """Command to move icons in the Design view. + """Command to move icons in the Design view.""" + def __init__(self, icon, project): + """ Args: icon (ProjectItemIcon): the icon project (SpineToolboxProject): project @@ -89,9 +93,10 @@ def _move_to(self, positions): class SetProjectDescriptionCommand(SpineToolboxCommand): - def __init__(self, project, description): - """Command to set the project description. + """Command to set the project description.""" + def __init__(self, project, description): + """ Args: project (SpineToolboxProject): the project description (str): The new description @@ -110,9 +115,10 @@ def undo(self): class AddProjectItemsCommand(SpineToolboxCommand): - def __init__(self, project, items_dict, item_factories, silent=True): - """Command to add items. + """Command to add items.""" + def __init__(self, project, items_dict, item_factories): + """ Args: project (SpineToolboxProject): the project items_dict (dict): a mapping from item name to item dict @@ -123,7 +129,6 @@ def __init__(self, project, items_dict, item_factories, silent=True): self._project = project self._items_dict = items_dict self._item_factories = item_factories - self._silent = silent if not items_dict: self.setObsolete(True) elif len(items_dict) == 1: @@ -132,7 +137,7 @@ def __init__(self, project, items_dict, item_factories, silent=True): self.setText("add multiple items") def redo(self): - self._project.restore_project_items(self._items_dict, self._item_factories, self._silent) + self._project.restore_project_items(self._items_dict, self._item_factories) def undo(self): for item_name in self._items_dict: @@ -140,9 +145,10 @@ def undo(self): class RemoveAllProjectItemsCommand(SpineToolboxCommand): - def __init__(self, project, item_factories, delete_data=False): - """Command to remove all items from project. + """Command to remove all items from project.""" + def __init__(self, project, item_factories, delete_data=False): + """ Args: project (SpineToolboxProject): the project item_factories (dict): a mapping from item type to ProjectItemFactory @@ -161,15 +167,16 @@ def redo(self): self._project.remove_item_by_name(name, self._delete_data) def undo(self): - self._project.restore_project_items(self._items_dict, self._item_factories, silent=True) + self._project.restore_project_items(self._items_dict, self._item_factories) for connection_dict in self._connection_dicts: self._project.add_connection(self._project.connection_from_dict(connection_dict), silent=True) class RemoveProjectItemsCommand(SpineToolboxCommand): - def __init__(self, project, item_factories, item_names, delete_data=False): - """Command to remove items. + """Command to remove items.""" + def __init__(self, project, item_factories, item_names, delete_data=False): + """ Args: project (SpineToolboxProject): The project item_factories (dict): a mapping from item type to ProjectItemFactory @@ -200,7 +207,7 @@ def redo(self): self._project.remove_item_by_name(name, self._delete_data) def undo(self): - self._project.restore_project_items(self._items_dict, self._item_factories, silent=True) + self._project.restore_project_items(self._items_dict, self._item_factories) for connection_dict in self._connection_dicts: self._project.add_connection(self._project.connection_from_dict(connection_dict), silent=True) for jump_dict in self._jump_dicts: @@ -208,9 +215,10 @@ def undo(self): class RenameProjectItemCommand(SpineToolboxCommand): - def __init__(self, project, previous_name, new_name): - """Command to rename project items. + """Command to rename project items.""" + def __init__(self, project, previous_name, new_name): + """ Args: project (SpineToolboxProject): the project previous_name (str): item's previous name @@ -237,9 +245,10 @@ def is_critical(self): class AddConnectionCommand(SpineToolboxCommand): - def __init__(self, project, source_name, source_position, destination_name, destination_position): - """Command to add connection between project items. + """Command to add connection between project items.""" + def __init__(self, project, source_name, source_position, destination_name, destination_position): + """ Args: project (SpineToolboxProject): project source_name (str): source item's name @@ -282,9 +291,10 @@ def undo(self): class RemoveConnectionsCommand(SpineToolboxCommand): - def __init__(self, project, connections): - """Command to remove links. + """Command to remove links.""" + def __init__(self, project, connections): + """ Args: project (SpineToolboxProject): project connections (list of LoggingConnection): the connections @@ -311,9 +321,10 @@ def undo(self): class AddJumpCommand(SpineToolboxCommand): - def __init__(self, project, source_name, source_position, destination_name, destination_position): - """Command to add a jump between project items. + """Command to add a jump between project items.""" + def __init__(self, project, source_name, source_position, destination_name, destination_position): + """ Args: project (SpineToolboxProject): project source_name (str): source item's name @@ -384,53 +395,66 @@ def undo(self): class SetJumpConditionCommand(SpineToolboxCommand): """Command to set jump condition.""" - def __init__(self, jump_properties, jump, condition): + def __init__(self, project, jump, jump_properties, condition): """ Args: - jump_properties (JumpPropertiesWidget): jump's properties tab + project (SpineToolboxProject): project jump (Jump): target jump - condition (str): jump condition + jump_properties (JumpPropertiesWidget): jump's properties tab + condition (dict): jump condition """ super().__init__() + self._project = project self._jump_properties = jump_properties - self._jump = jump + self._jump_source = jump.source + self._jump_destination = jump.destination self._condition = condition self._previous_condition = jump.condition - self.setText("change loop condition") + self.setText(f"change loop condition for jump {jump.name}") def redo(self): - self._jump_properties.set_condition(self._jump, self._condition) + jump = self._project.find_jump(self._jump_source, self._jump_destination) + self._jump_properties.set_condition(jump, self._condition) def undo(self): - self._jump_properties.set_condition(self._jump, self._previous_condition) + jump = self._project.find_jump(self._jump_source, self._jump_destination) + self._jump_properties.set_condition(jump, self._previous_condition) class UpdateJumpCmdLineArgsCommand(SpineToolboxCommand): - def __init__(self, jump_properties, jump, cmd_line_args): - """Command to update Jump command line args. + """Command to update Jump command line args.""" + def __init__(self, project, jump, jump_properties, cmd_line_args): + """ Args: + project (SpineToolboxProject): project + jump (Jump): jump jump_properties (JumpPropertiesWidget): the item cmd_line_args (list): list of command line args """ super().__init__() + self._project = project self._jump_properties = jump_properties - self._jump = jump + self._jump_source = jump.source + self._jump_destination = jump.destination self._redo_cmd_line_args = cmd_line_args - self._undo_cmd_line_args = self._jump.cmd_line_args - self.setText(f"change command line arguments of {jump.name}") + self._undo_cmd_line_args = jump.cmd_line_args + self.setText(f"change command line arguments of jump {jump.name}") def redo(self): - self._jump_properties.update_cmd_line_args(self._jump, self._redo_cmd_line_args) + jump = self._project.find_jump(self._jump_source, self._jump_destination) + self._jump_properties.update_cmd_line_args(jump, self._redo_cmd_line_args) def undo(self): - self._jump_properties.update_cmd_line_args(self._jump, self._undo_cmd_line_args) + jump = self._project.find_jump(self._jump_source, self._jump_destination) + self._jump_properties.update_cmd_line_args(jump, self._undo_cmd_line_args) class SetFiltersOnlineCommand(SpineToolboxCommand): - def __init__(self, project, connection, resource, filter_type, online): - """Command to toggle filter value. + """Command to toggle filter value.""" + def __init__(self, project, connection, resource, filter_type, online): + """ Args: project (SpineToolboxProject): project connection (Connection): connection @@ -485,10 +509,39 @@ def undo(self): connection.set_filter_default_online_status(not self._checked) +class SetConnectionFilterTypeEnabled(SpineToolboxCommand): + """Command to enable and disable connection's filter types.""" + + def __init__(self, project, connection, filter_type, enabled): + """ + Args: + project (SpineToolboxProject): project + connection (LoggingConnection): connection + filter_type (str): filter type + enabled (bool): whether filter type is enabled + """ + super().__init__() + self.setText(f"change {connection.name}") + self._project = project + self._source_name = connection.source + self._destination_name = connection.destination + self._filter_type = filter_type + self._enabled = enabled + + def redo(self): + connection = self._project.find_connection(self._source_name, self._destination_name) + connection.set_filter_type_enabled(self._filter_type, self._enabled) + + def undo(self): + connection = self._project.find_connection(self._source_name, self._destination_name) + connection.set_filter_type_enabled(self._filter_type, not self._enabled) + + class SetConnectionOptionsCommand(SpineToolboxCommand): - def __init__(self, project, connection, options): - """Command to set connection options. + """Command to set connection options.""" + def __init__(self, project, connection, options): + """ Args: project (SpineToolboxProject): project connection (LoggingConnection): project @@ -513,9 +566,10 @@ def undo(self): class AddSpecificationCommand(SpineToolboxCommand): - def __init__(self, project, specification, save_to_disk): - """Command to add item specification to a project. + """Command to add item specification to a project.""" + def __init__(self, project, specification, save_to_disk): + """ Args: project (ToolboxUI): the toolbox specification (ProjectItemSpecification): the spec @@ -540,9 +594,10 @@ def undo(self): class ReplaceSpecificationCommand(SpineToolboxCommand): - def __init__(self, project, name, specification): - """Command to replace item specification in project. + """Command to replace item specification in project.""" + def __init__(self, project, name, specification): + """ Args: project (ToolboxUI): the toolbox name (str): the name of the spec to be replaced @@ -569,9 +624,10 @@ def is_critical(self): class RemoveSpecificationCommand(SpineToolboxCommand): - def __init__(self, project, name): - """Command to remove specs from a project. + """Command to remove specs from a project.""" + def __init__(self, project, name): + """ Args: project (SpineToolboxProject): the project name (str): specification's name @@ -590,9 +646,10 @@ def undo(self): class SaveSpecificationAsCommand(SpineToolboxCommand): - def __init__(self, project, name, path): - """Command to remove item specs from a project. + """Command to remove item specs from a project.""" + def __init__(self, project, name, path): + """ Args: project (SpineToolboxProject): the project name (str): specification's name diff --git a/spinetoolbox/project_item/__init__.py b/spinetoolbox/project_item/__init__.py index b8ab876f9..22dd1fb3b 100644 --- a/spinetoolbox/project_item/__init__.py +++ b/spinetoolbox/project_item/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -This subpackage contains base classes for project items. -""" +"""This subpackage contains base classes for project items.""" diff --git a/spinetoolbox/project_item/logging_connection.py b/spinetoolbox/project_item/logging_connection.py index c5d9eaa4c..30a168da8 100644 --- a/spinetoolbox/project_item/logging_connection.py +++ b/spinetoolbox/project_item/logging_connection.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,10 +9,10 @@ # 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 . ###################################################################################################################### -"""Contains logging connection and jump classes.""" +"""Contains logging connection and jump classes.""" from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE -from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE +from spinedb_api.filters.alternative_filter import ALTERNATIVE_FILTER_TYPE from spinedb_api import DatabaseMapping, SpineDBAPIError, SpineDBVersionError from spine_engine.project_item.connection import ResourceConvertingConnection, Jump, ConnectionBase, FilterSettings from ..log_mixin import LogMixin @@ -20,6 +21,9 @@ from ..fetch_parent import FlexibleFetchParent +_DATABASE_ITEM_TYPE = {ALTERNATIVE_FILTER_TYPE: "alternative", SCENARIO_FILTER_TYPE: "scenario"} + + class HeadlessConnection(ResourceConvertingConnection): """A project item connection that is compatible with headless mode.""" @@ -55,6 +59,15 @@ def set_filter_enabled(self, resource_label, filter_type, filter_name, enabled): ) specific_filter_settings[filter_name] = enabled + def set_filter_type_enabled(self, filter_type, enabled): + """Enables or disables a filter type. + + Args: + filter_type (str): filter type + enabled (bool): True to enable the filter type, False to disable it + """ + self._filter_settings.enabled_filter_types[filter_type] = enabled + def _convert_legacy_resource_filter_ids_to_filter_settings(self): """Converts legacy resource filter ids to filter settings. @@ -80,15 +93,8 @@ def _convert_legacy_resource_filter_ids_to_filter_settings(self): ).setdefault(SCENARIO_FILTER_TYPE, {}) for row in db_map.query(db_map.scenario_sq): specific_filter_settings[row.name]: row.id = row.id in scenario_filter_ids - tool_filter_ids = resource_filter_ids.get(TOOL_FILTER_TYPE) - if tool_filter_ids is not None: - specific_filter_settings = self._filter_settings.known_filters.setdefault( - resource.label, {} - ).setdefault(TOOL_FILTER_TYPE, {}) - for row in db_map.query(db_map.tool_sq): - specific_filter_settings[row.name] = row.id in tool_filter_ids finally: - db_map.connection.close() + db_map.close() self._legacy_resource_filter_ids = None @staticmethod @@ -157,7 +163,7 @@ def graphics_item(self): return self.link def has_filters(self): - """Returns True if connection has scenario or tool filters. + """Returns True if connection has any filters. Returns: bool: True if connection has filters, False otherwise @@ -169,20 +175,14 @@ def has_filters(self): db_map = self._get_db_map(url, ignore_version_error=True) if db_map is None: continue - available_scenarios = { - x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "scenario", only_visible=True) - } - scenario_filters = self._filter_settings.known_filters.get(resource.label, {}).get(SCENARIO_FILTER_TYPE, {}) - if any(enabled for s, enabled in scenario_filters.items() if s in available_scenarios): - return True - if self._filter_settings.auto_online and any(name not in scenario_filters for name in available_scenarios): - return True - available_tools = {x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "tool", only_visible=True)} - tool_filters = self._filter_settings.known_filters.get(resource.label, {}).get(TOOL_FILTER_TYPE, {}) - if any(enabled for t, enabled in tool_filters.items() if t in available_tools): - return True - if self._filter_settings.auto_online and any(name not in tool_filters for name in available_tools): - return True + known_filters = self._filter_settings.known_filters.get(resource.label, {}) + for filter_type, item_type in _DATABASE_ITEM_TYPE.items(): + available = {x["name"] for x in self._toolbox.db_mngr.get_items(db_map, item_type)} + filters = known_filters.get(filter_type, {}) + if any(enabled for s, enabled in filters.items() if s in available): + return True + if self._filter_settings.auto_online and any(name not in filters for name in available): + return True return False def _get_db_map(self, url, ignore_version_error=False): @@ -222,7 +222,7 @@ def _make_fetch_parent(self, db_map, item_type): def _fetch_more_if_possible(self): for db_map in self._db_maps.values(): - for item_type in ("scenario", "tool"): + for item_type in ("scenario",): fetch_parent = self._make_fetch_parent(db_map, item_type) if self._toolbox.db_mngr.can_fetch_more(db_map, fetch_parent): self._toolbox.db_mngr.fetch_more(db_map, fetch_parent) @@ -241,17 +241,23 @@ def receive_session_rolled_back(self, db_map): def receive_error_msg(self, _db_map_error_log): pass - def get_scenario_names(self, url): + def get_filter_item_names(self, filter_type, url): db_map = self._get_db_map(url) if db_map is None: return [] - return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "scenario", only_visible=True)) - - def get_tool_names(self, url): - db_map = self._get_db_map(url) - if db_map is None: - return [] - return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, "tool", only_visible=True)) + item_type = _DATABASE_ITEM_TYPE[filter_type] + return sorted(x["name"] for x in self._toolbox.db_mngr.get_items(db_map, item_type)) + + def _do_purge_before_writing(self, resources): + purged_urls = super()._do_purge_before_writing(resources) + committed_db_maps = set() + for url in purged_urls: + db_map = self._toolbox.db_mngr.db_map(url) + if db_map: + committed_db_maps.add(db_map) + if committed_db_maps: + self._toolbox.db_mngr.notify_session_committed(self, *committed_db_maps) + return purged_urls def may_have_filters(self): """Returns whether this connection may have filters. @@ -317,8 +323,8 @@ def set_online(self, resource, filter_type, online): Args: resource (str): Resource label - filter_type (str): Either SCENARIO_FILTER_TYPE or TOOL_FILTER_TYPE, for now. - online (dict): mapping from scenario/tool name to online flag + filter_type (str): filter type + online (dict): mapping from scenario name to online flag """ self._filter_settings.known_filters.setdefault(resource, {}).setdefault(filter_type, {}).update(online) @@ -336,6 +342,13 @@ def refresh_resource_filter_model(self): """Makes resource filter mode fetch filter data from database.""" self.resource_filter_model.build_tree() + def set_filter_type_enabled(self, filter_type, enabled): + """See base class.""" + super().set_filter_type_enabled(filter_type, enabled) + self.resource_filter_model.set_filter_type_enabled(filter_type, enabled) + if self is self._toolbox.active_link_item: + self._toolbox.link_properties_widgets[LoggingConnection].set_filter_type_enabled(filter_type, enabled) + def receive_resources_from_source(self, resources): """See base class.""" super().receive_resources_from_source(resources) @@ -373,9 +386,12 @@ def _check_available_filters(self): Returns: FilterSettings: filter settings containing only filters that exist in source databases """ - filter_settings = FilterSettings(auto_online=self._filter_settings.auto_online) + filter_settings = FilterSettings( + auto_online=self._filter_settings.auto_online, + enabled_filter_types=self._filter_settings.enabled_filter_types, + ) for resource in self._resources: - for filter_type in (SCENARIO_FILTER_TYPE, TOOL_FILTER_TYPE): + for filter_type in (SCENARIO_FILTER_TYPE, ALTERNATIVE_FILTER_TYPE): online_filters = self._resource_filters_online(resource, filter_type) if online_filters is not None: filter_settings.known_filters.setdefault(resource.label, {})[filter_type] = online_filters @@ -388,10 +404,8 @@ def _resource_filters_online(self, resource, filter_type): db_map = self._get_db_map(url) if db_map is None: return None - db_item_type = {SCENARIO_FILTER_TYPE: "scenario", TOOL_FILTER_TYPE: "tool"}[filter_type] - available_filters = ( - x["name"] for x in self._toolbox.db_mngr.get_items(db_map, db_item_type, only_visible=True) - ) + db_item_type = _DATABASE_ITEM_TYPE[filter_type] + available_filters = (x["name"] for x in self._toolbox.db_mngr.get_items(db_map, db_item_type)) specific_filter_settings = self._filter_settings.known_filters.get(resource.label, {}).get(filter_type, {}) checked_specific_filter_settings = {} for name in sorted(available_filters): diff --git a/spinetoolbox/project_item/project_item.py b/spinetoolbox/project_item/project_item.py index 644989799..4588449ed 100644 --- a/spinetoolbox/project_item/project_item.py +++ b/spinetoolbox/project_item/project_item.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,13 +9,11 @@ # 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 . ###################################################################################################################### -""" -Contains base classes for project items and item factories. -""" +"""Contains base classes for project items and item factories.""" import os import logging -from PySide6.QtCore import Slot, Qt +from PySide6.QtCore import Slot from spine_engine.utils.helpers import shorten from ..helpers import create_dir, open_url from ..metaobject import MetaObject @@ -77,14 +76,9 @@ def item_type(): """ raise NotImplementedError() - @staticmethod - def item_category(): - """Item's category. - - Returns: - str: category name - """ - raise NotImplementedError() + @property + def project(self): + return self._project @property def logger(self): @@ -164,7 +158,9 @@ def set_specification(self, specification): """Pushes a new SetItemSpecificationCommand to the toolbox' undo stack.""" if specification == self._specification: return - self._toolbox.undo_stack.push(SetItemSpecificationCommand(self, specification, self.undo_specification())) + self._toolbox.undo_stack.push( + SetItemSpecificationCommand(self.name, specification, self.undo_specification(), self._project) + ) def do_set_specification(self, specification): """Sets specification for this item. Removes specification if None given as argument. @@ -203,8 +199,13 @@ def add_notification(self, text): self.get_icon().exclamation_icon.add_notification(text) def remove_notification(self, text): + """Remove the first notification that includes given subtext.""" self.get_icon().exclamation_icon.remove_notification(text) + def clear_other_notifications(self, text): + """Remove notifications that don't include the given subtext.""" + self.get_icon().exclamation_icon.clear_other_notifications(text) + def set_rank(self, rank): """Set rank of this item for displaying in the design view.""" if rank is not None: @@ -220,7 +221,7 @@ def handle_execution_successful(self, execution_direction, engine_state): """Performs item dependent actions after the execution item has finished successfully. Args: - execution_direction (str): "FORWARD" or "BACKWARD" + execution_direction (ExecutionDirection): ExecutionDirection.FORWARD or ExecutionDirection.BACKWARD engine_state: engine state after item's execution """ @@ -305,23 +306,6 @@ def replace_resources_from_downstream(self, old, new): new (list of ProjectItemResource): new resources """ - def invalidate_workflow(self, edges): - """Notifies that this item's workflow is not acyclic. - - Args: - edges (list): A list of edges that make the graph acyclic after removing them. - """ - edges = ", ".join("{0} -> {1}".format(*edge) for edge in edges) - self.clear_notifications() - self.set_rank(None) - self.add_notification( - "The workflow defined for this item has loops and thus cannot be executed. " - f"Possible fix: remove link(s) {edges}." - ) - - def revalidate_workflow(self): - self.remove_notification("The workflow defined for this item has loops and thus cannot be executed.") - def item_dict(self): """Returns a dictionary corresponding to this item. diff --git a/spinetoolbox/project_item/project_item_factory.py b/spinetoolbox/project_item/project_item_factory.py index 71cdbd83b..e7825a787 100644 --- a/spinetoolbox/project_item/project_item_factory.py +++ b/spinetoolbox/project_item/project_item_factory.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,9 +9,8 @@ # 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 . ###################################################################################################################### -""" -Contains base classes for project items and item factories. -""" + +"""Contains base classes for project items and item factories.""" class ProjectItemFactory: diff --git a/spinetoolbox/project_item/specification_editor_window.py b/spinetoolbox/project_item/specification_editor_window.py index 2db92545b..a44c78a76 100644 --- a/spinetoolbox/project_item/specification_editor_window.py +++ b/spinetoolbox/project_item/specification_editor_window.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Items. # Spine Items 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) @@ -9,10 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains SpecificationEditorWindowBase and ChangeSpecPropertyCommand -""" - +"""Contains SpecificationEditorWindowBase and ChangeSpecPropertyCommand""" +from enum import IntEnum, unique from PySide6.QtGui import QKeySequence, QIcon, QUndoStack, QAction, QUndoCommand from PySide6.QtCore import Signal, Slot, Qt from PySide6.QtWidgets import ( @@ -29,19 +28,42 @@ QToolButton, ) from spinetoolbox.widgets.notification import ChangeNotifier, Notification -from spinetoolbox.helpers import CharIconEngine, restore_ui, save_ui +from spinetoolbox.helpers import CharIconEngine, restore_ui, save_ui, SealCommand + + +class UniqueCommandId: + _NEXT_ID = 1 + + @classmethod + def unique_id(cls): + """Returns a new unique command id. + + Returns: + int: unique id + """ + new_id = cls._NEXT_ID + cls._NEXT_ID += 1 + return new_id + + +@unique +class CommandId(IntEnum): + NONE = -1 + NAME_UPDATE = UniqueCommandId.unique_id() + DESCRIPTION_UPDATE = UniqueCommandId.unique_id() class ChangeSpecPropertyCommand(QUndoCommand): """Command to set specification properties.""" - def __init__(self, callback, new_value, old_value, cmd_name): + def __init__(self, callback, new_value, old_value, cmd_name, command_id=CommandId.NONE): """ Args: - callback (function): Function to call to set the spec property. - new_value (any): new value - old_value (any): old value + callback (Callable): Function to call to set the spec property. + new_value (Any): new value + old_value (Any): old value cmd_name (str): command name + command_id (IntEnum): command id """ super().__init__() self._callback = callback @@ -49,6 +71,8 @@ def __init__(self, callback, new_value, old_value, cmd_name): self._old_value = old_value self.setText(cmd_name) self.setObsolete(new_value == old_value) + self._id = command_id + self._sealed = False def redo(self): self._callback(self._new_value) @@ -56,6 +80,21 @@ def redo(self): def undo(self): self._callback(self._old_value) + def id(self): + return self._id.value + + def mergeWith(self, other): + if not isinstance(other, ChangeSpecPropertyCommand): + self._sealed = True + return False + if self._sealed or self.id() != other.id(): + return False + if self._old_value == other._new_value: + self.setObsolete(True) + else: + self._new_value = other._new_value + return True + class SpecificationEditorWindowBase(QMainWindow): """Base class for spec editors.""" @@ -118,12 +157,11 @@ def _restore_dock_widgets(self): """Restores dockWidgets to some default state. Called in the constructor, before restoring the ui from settings. Reimplement in subclasses if needed.""" - def _make_new_specification(self, spec_name, exiting=None): + def _make_new_specification(self, spec_name): """Returns a ProjectItemSpecification from current form settings. Args: spec_name (str): Name of the spec - exiting (bool, optional): Set as True if called when trying to exit the editor window Returns: ProjectItemSpecification @@ -189,7 +227,7 @@ def _save(self, exiting=None): return self.prompt_exit_without_saving() self.show_error("Please enter a name for the specification.") return False - spec = self._make_new_specification(name, exiting) + spec = self._make_new_specification(name) if spec is None: return self.prompt_exit_without_saving() if exiting else False if not self._original_spec_name: @@ -290,9 +328,9 @@ def __init__(self, parent, spec, undo_stack): self.setMovable(False) widget = QWidget() layout = QHBoxLayout(widget) - layout.addWidget(QLabel("Name")) + layout.addWidget(QLabel("Name:")) layout.addWidget(self._line_edit_name) - layout.addWidget(QLabel("Description")) + layout.addWidget(QLabel("Description:")) layout.addWidget(self._line_edit_description) layout.setContentsMargins(3, 3, 3, 3) layout.setStretchFactor(self._line_edit_name, 1) @@ -300,7 +338,7 @@ def __init__(self, parent, spec, undo_stack): self.addWidget(widget) toolbox_icon = QIcon(":/symbols/Spine_symbol.png") self.show_toolbox_action = self.addAction(toolbox_icon, "Show Spine Toolbox (Ctrl+ESC)") - self.show_toolbox_action.setShortcut(Qt.CTRL | Qt.Key_Escape) + self.show_toolbox_action.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_Escape.value)) self.menu = self._make_main_menu() self.save_action = self.menu.addAction("Save") self.duplicate_action = self.menu.addAction("Duplicate") @@ -309,7 +347,7 @@ def __init__(self, parent, spec, undo_stack): self.duplicate_action.setEnabled(self._parent.specification is not None) self.save_action.setShortcut(QKeySequence.Save) self.save_action.setIcon(QIcon(":/icons/menu_icons/save_solid.svg")) - self.duplicate_action.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_D)) + self.duplicate_action.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_D.value)) self.duplicate_action.setIcon(QIcon(":/icons/menu_icons/copy.svg")) self.close_action.setShortcut(QKeySequence.Close) self.close_action.setIcon(QIcon(":/icons/menu_icons/window-close.svg")) @@ -317,8 +355,10 @@ def __init__(self, parent, spec, undo_stack): if spec: self.do_set_name(spec.name) self.do_set_description(spec.description) - self._line_edit_name.editingFinished.connect(self._set_name) - self._line_edit_description.editingFinished.connect(self._set_description) + self._line_edit_name.textEdited.connect(self._update_name) + self._line_edit_name.editingFinished.connect(self._finish_name_editing) + self._line_edit_description.textEdited.connect(self._update_description) + self._line_edit_description.editingFinished.connect(self._finish_description_editing) def _make_main_menu(self): menu = QMenu(self) @@ -328,40 +368,66 @@ def _make_main_menu(self): menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) action = QAction(self) action.triggered.connect(menu_button.showMenu) - keys = [QKeySequence(Qt.ALT | Qt.Key_F), QKeySequence(Qt.ALT | Qt.Key_E)] + keys = [ + QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_F.value), + QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_E.value), + ] action.setShortcuts(keys) self._parent.addAction(action) keys_str = ", ".join([key.toString() for key in keys]) menu_button.setToolTip(f"

Main menu ({keys_str})

") return menu - @Slot() - def _set_name(self): - if self.name() == self._current_name: - return + @Slot(str) + def _update_name(self, name): + """Pushes a command to undo stack that updates the specification name. + + Args: + name (str): updated name + """ self._undo_stack.push( - ChangeSpecPropertyCommand(self.do_set_name, self.name(), self._current_name, "change specification name") + ChangeSpecPropertyCommand( + self.do_set_name, name, self._current_name, "change specification name", CommandId.NAME_UPDATE + ) ) @Slot() - def _set_description(self): - if self.description() == self._current_description: - return + def _finish_name_editing(self): + """Seals the last undo command.""" + self._undo_stack.push(SealCommand(CommandId.NAME_UPDATE.value)) + + @Slot(str) + def _update_description(self, description): + """Pushes a command to undo stack that updates the specification description. + + Args: + description (str): updated description + """ self._undo_stack.push( ChangeSpecPropertyCommand( self.do_set_description, self.description(), self._current_description, "change specification description", + CommandId.DESCRIPTION_UPDATE, ) ) + @Slot() + def _finish_description_editing(self): + """Seals the last undo command.""" + self._undo_stack.push(SealCommand(CommandId.DESCRIPTION_UPDATE.value)) + def do_set_name(self, name): + self.name_changed.emit(name) + if self._line_edit_name.text() == name: + return self._current_name = name self._line_edit_name.setText(name) - self.name_changed.emit(name) def do_set_description(self, description): + if self._line_edit_description.text() == description: + return self._current_description = description self._line_edit_description.setText(description) diff --git a/spinetoolbox/project_item_icon.py b/spinetoolbox/project_item_icon.py index b396f14bf..de2343047 100644 --- a/spinetoolbox/project_item_icon.py +++ b/spinetoolbox/project_item_icon.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,21 +10,16 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for drawing graphics items on QGraphicsScene. -""" - +"""Classes for drawing graphics items on QGraphicsScene.""" import math from PySide6.QtCore import Qt, QPointF, QRectF, QLineF from PySide6.QtWidgets import ( QGraphicsItem, QGraphicsTextItem, - QGraphicsSimpleTextItem, QGraphicsPathItem, QGraphicsEllipseItem, QGraphicsColorizeEffect, QGraphicsDropShadowEffect, - QApplication, QToolTip, QStyle, ) @@ -32,7 +28,6 @@ QPen, QBrush, QTextCursor, - QPalette, QTextBlockFormat, QFont, QPainterPath, @@ -81,9 +76,11 @@ def __init__(self, toolbox, icon_file, icon_color): self.rank_icon = RankIcon(self) # Make item name graphics item. self._name = "" - self.name_item = QGraphicsSimpleTextItem(self._name) + self.name_item = QGraphicsTextItem(self._name) self.name_item.setZValue(100) self.set_name_attributes() # Set font, size, position, etc. + self.spec_item = None # For displaying Tool Spec icon + self.spec_item_renderer = None # Make connector buttons self.connectors = dict( bottom=ConnectorButton(toolbox, self, position="bottom"), @@ -97,6 +94,31 @@ def __init__(self, toolbox, icon_file, icon_color): self.setGraphicsEffect(shadow_effect) self._update_path() + def add_specification_icon(self, spec_icon_path): + """Adds an SVG icon to bottom left corner of the item icon based on Tool Specification type. + + Args: + spec_icon_path (str): Path to icon resource file. + """ + self.spec_item = QGraphicsSvgItem(self) + self.spec_item_renderer = QSvgRenderer() + loading_ok = self.spec_item_renderer.load(spec_icon_path) + if not loading_ok: + self._toolbox.msg_error.emit(f"Loading SVG icon from resource {spec_icon_path} failed") + return + size = self.spec_item_renderer.defaultSize() + self.spec_item.setSharedRenderer(self.spec_item_renderer) + self.spec_item.setElementId("") + dim_max = max(size.width(), size.height()) + rect_w = 0.3 * self.rect().width() # Parent rect width + self.spec_item.setScale(rect_w / dim_max) + self.spec_item.setPos(self.sceneBoundingRect().bottomLeft() - self.spec_item.sceneBoundingRect().center()) + + def remove_specification_icon(self): + """Removes the specification icon SVG from the scene.""" + self.spec_item.setParentItem(None) + self.spec_item = None + def rect(self): return self._rect @@ -126,8 +148,7 @@ def _do_update_path(self, rounded): self._selection_halo.setPen(selection_pen) def finalize(self, name, x, y): - """ - Names the icon and moves it by given amount. + """Names the icon and moves it by a given amount. Args: name (str): icon's name @@ -138,7 +159,7 @@ def finalize(self, name, x, y): self.update_name_item(name) def _setup(self): - """Setup item's attributes.""" + """Sets up item attributes.""" self.colorizer.setColor(self._icon_color) background_color = fix_lightness_color(self._icon_color) gradient = QRadialGradient(self._rect.center(), 1 * self._rect.width()) @@ -187,25 +208,28 @@ def name(self): return self._name def update_name_item(self, new_name): - """Set a new text to name item. + """Sets a new text to name item. Args: new_name (str): icon's name """ self._name = new_name - self.name_item.setText(new_name) + self.name_item.setPlainText(new_name) self._reposition_name_item() + self.name_item.setTextWidth(100) def set_name_attributes(self): - """Set name QGraphicsSimpleTextItem attributes (font, size, position, etc.)""" - # Set font size and style + """Sets name item attributes (font, size, style, alignment).""" font = self.name_item.font() font.setPixelSize(self.FONT_SIZE_PIXELS) font.setBold(True) self.name_item.setFont(font) + option = self.name_item.document().defaultTextOption() + option.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.name_item.document().setDefaultTextOption(option) def _reposition_name_item(self): - """Set name item position (centered on top of the master icon).""" + """Sets name item position (centered on top of the master icon).""" main_rect = self.sceneBoundingRect() name_rect = self.name_item.sceneBoundingRect() self.name_item.setPos(main_rect.center().x() - name_rect.width() / 2, main_rect.y() - name_rect.height() - 4) @@ -299,9 +323,13 @@ def update_links_geometry(self): if not scene: return icon_group = scene.icon_group | {self} - scene.dirty_links |= set( - link for icon in icon_group for conn in icon.connectors.values() for link in conn.links - ) + dirty_links = set(link for icon in icon_group for conn in icon.connectors.values() for link in conn.links) + if not dirty_links: + return + qsettings = self._toolbox.qsettings() + curved_links = qsettings.value("appSettings/curvedLinks", defaultValue="false") == "true" + for link in dirty_links: + link.update_geometry(curved_links) def mouseReleaseEvent(self, event): """Clears pre-bump rects, and pushes a move icon command if necessary.""" @@ -328,14 +356,14 @@ def contextMenuEvent(self, event): event.accept() self.scene().clearSelection() self.setSelected(True) - ind = self._toolbox.project_item_model.find_item(self.name()) - self._toolbox.show_project_or_item_context_menu(event.screenPos(), ind) + item = self._toolbox.project().get_item(self.name()) + self._toolbox.show_project_or_item_context_menu(event.screenPos(), item) def itemChange(self, change, value): """ Reacts to item removal and position changes. - In particular, destroys the drop shadow effect when the items is removed from a scene + In particular, destroys the drop shadow effect when the item is removed from a scene and keeps track of item's movements on the scene. Args: @@ -414,11 +442,6 @@ def _restablish_bumped_items(self): pass return restablished - def select_item(self): - """Update GUI to show the details of the selected item.""" - ind = self._toolbox.project_item_model.find_item(self.name()) - self._toolbox.ui.treeView_project.setCurrentIndex(ind) - def paint(self, painter, option, widget=None): """Sets a dashed pen if selected.""" selected = bool(option.state & QStyle.StateFlag.State_Selected) @@ -501,7 +524,6 @@ def mousePressEvent(self, event): if event.button() != Qt.LeftButton: event.accept() return - self._parent.select_item() self._start_link(event) def _start_link(self, event): @@ -571,7 +593,7 @@ def __init__(self, parent): self._parent = parent self._execution_state = "not started" self._text_item = QGraphicsTextItem(self) - font = QFont('Font Awesome 5 Free Solid') + font = QFont("Font Awesome 5 Free Solid") self._text_item.setFont(font) parent_rect = parent.rect() self.setRect(0, 0, 0.5 * parent_rect.width(), 0.5 * parent_rect.height()) @@ -646,7 +668,7 @@ def __init__(self, parent): super().__init__(parent) self._parent = parent self._notifications = list() - font = QFont('Font Awesome 5 Free Solid') + font = QFont("Font Awesome 5 Free Solid") font.setPixelSize(self.FONT_SIZE_PIXELS) self.setFont(font) self.setDefaultTextColor(QColor("red")) @@ -662,6 +684,14 @@ def clear_notifications(self): self._notifications.clear() self.hide() + def clear_other_notifications(self, subtext): + """Remove notifications that don't include the given subtext.""" + k = next((i for i, text in enumerate(self._notifications) if subtext not in text), None) + if k is not None: + self._notifications.pop(k) + if not self._notifications: + self.hide() + def add_notification(self, text): """Add a notification.""" self._notifications.append(text) @@ -707,7 +737,7 @@ def __init__(self, parent): self._parent = parent self._rect = parent.component_rect self.bg = QGraphicsPathItem(self) - bg_brush = QApplication.palette().brush(QPalette.ToolTipBase) + bg_brush = QBrush(QColor(Qt.GlobalColor.white)) self.bg.setBrush(bg_brush) self.bg.setFlag(QGraphicsItem.ItemStacksBehindParent) self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False) diff --git a/spinetoolbox/project_settings.py b/spinetoolbox/project_settings.py index ff5d9553f..d65f99353 100644 --- a/spinetoolbox/project_settings.py +++ b/spinetoolbox/project_settings.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,8 +9,8 @@ # 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 . ###################################################################################################################### -"""Contains project-specific settings.""" +"""Contains project-specific settings.""" import dataclasses diff --git a/spinetoolbox/project_upgrader.py b/spinetoolbox/project_upgrader.py index 199f8da33..792d84dba 100644 --- a/spinetoolbox/project_upgrader.py +++ b/spinetoolbox/project_upgrader.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,11 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains ProjectUpgrader class used in upgrading and converting projects -and project dicts from earlier versions to the latest version. -""" - +"""Contains ProjectUpgrader class used in upgrading and converting projects +and project dicts from earlier versions to the latest version.""" import shutil import os import json @@ -53,22 +51,36 @@ def upgrade(self, project_dict, project_dir): f"Opening project {project_dir} failed. The project's version is {v}, while " f"this version of Spine Toolbox supports project versions up to and " f"including {LATEST_PROJECT_VERSION}. To open this project, you should " - f"upgrade Spine Toolbox" + f"upgrade Spine Toolbox." ) return False if v < LATEST_PROJECT_VERSION: + if not self.confirm_upgrade(project_dir): + return False # Back up project.json file before upgrading if not self.backup_project_file(project_dir, v): - self._toolbox.msg_error.emit("Upgrading project failed") + self._toolbox.msg_error.emit(f"Upgrading project {project_dir} failed") return False upgraded_dict = self.upgrade_to_latest(v, project_dict, project_dir) # Force save project dict to project.json if not self.force_save(upgraded_dict, project_dir): - self._toolbox.msg_error.emit("Upgrading project failed") + self._toolbox.msg_error.emit(f"Upgrading project {project_dir} failed") return False return upgraded_dict return project_dict + def confirm_upgrade(self, project_dir): + """Asks user whether to upgrade the project to a new version.""" + button = QMessageBox.question( + self._toolbox, + "Upgrade project?", + f"Project {project_dir} needs an upgrade to work " + f"with this version of Spine Toolbox.

Upgrade project?", + ) + if button == QMessageBox.StandardButton.Yes: + return True + return False + def upgrade_to_latest(self, v, project_dict, project_dir): """Upgrades the given project dictionary to the latest version. @@ -80,8 +92,8 @@ def upgrade_to_latest(self, v, project_dict, project_dir): Returns: dict: Upgraded project dictionary """ - # TODO: Fix upgrade_vx_to_vx() methods so they do not depend on self._toolbox.item_factories because these are - # TODO: going to change + # Note: upgrade_vx_to_vx() methods should not depend on self._toolbox.item_factories + # because these are likely to change while v < LATEST_PROJECT_VERSION: if v == 1: project_dict = self.upgrade_v1_to_v2(project_dict, self._toolbox.item_factories) @@ -103,6 +115,10 @@ def upgrade_to_latest(self, v, project_dict, project_dir): project_dict = self.upgrade_v9_to_v10(project_dict) elif v == 10: project_dict = self.upgrade_v10_to_v11(project_dict) + elif v == 11: + project_dict = self.upgrade_v11_to_v12(project_dict) + elif v == 12: + project_dict = self.upgrade_v12_to_v13(project_dict) v += 1 self._toolbox.msg_success.emit(f"Project upgraded to version {v}") return project_dict @@ -523,6 +539,44 @@ def upgrade_v10_to_v11(old): new["project"]["settings"] = ProjectSettings().to_dict() return new + @staticmethod + def upgrade_v11_to_v12(old): + """Upgrades version 11 project dictionary to version 12. + + Changes: + 1. Julia's execution settings are now Tool Spec settings instead of global settings + Execution settings are local user settings so this only updates the project version + to make sure that these projects cannot be opened with an older Toolbox version. + + Args: + old (dict): Version 11 project dictionary + + Returns: + dict: Version 12 project dictionary + """ + new = copy.deepcopy(old) + new["project"]["version"] = 12 + return new + + @staticmethod + def upgrade_v12_to_v13(old): + """Upgrades version 12 project dictionary to version 13. + + Changes: + 1. Connections now have enabled filter types field. + Old projects should open just fine so this only updates the project version + to make sure that these projects cannot be opened with an older Toolbox version. + + Args: + old (dict): Version 12 project dictionary + + Returns: + dict: Version 13 project dictionary + """ + new = copy.deepcopy(old) + new["project"]["version"] = 13 + return new + @staticmethod def make_unique_importer_specification_name(importer_name, label, k): return f"{importer_name} - {os.path.basename(label['path'])} - {k}" @@ -579,8 +633,8 @@ def is_valid(self, v, p): return self.is_valid_v2_to_v8(p, v) if 9 <= v <= 10: return self.is_valid_v9_to_v10(p) - if v == 11: - return self.is_valid_v11(p) + if 11 <= v <= 13: + return self.is_valid_v11_to_v12(p) raise NotImplementedError(f"No validity check available for version {v}") def is_valid_v1(self, p): @@ -709,9 +763,9 @@ def is_valid_v9_to_v10(self, p): return False return True - def is_valid_v11(self, p): + def is_valid_v11_to_v12(self, p): """Checks that the given project JSON dictionary contains - a valid version 11 Spine Toolbox project. Valid meaning, that + a valid version 11 or 12 Spine Toolbox project. Valid meaning, that it contains all required keys and values are of the correct type. @@ -719,7 +773,7 @@ def is_valid_v11(self, p): p (dict): Project information JSON Returns: - bool: True if project is a valid version 11 project, False otherwise + bool: True if project is a valid version 11 or 12 project, False otherwise """ if "project" not in p: self._toolbox.msg_error.emit("Invalid project.json file. Key 'project' not found.") diff --git a/spinetoolbox/qthread_pool_executor.py b/spinetoolbox/qthread_pool_executor.py index 6dbd5e614..03d93843a 100644 --- a/spinetoolbox/qthread_pool_executor.py +++ b/spinetoolbox/qthread_pool_executor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Qt-based thread pool executor. -""" +"""Qt-based thread pool executor.""" import os from PySide6.QtCore import QMutex, QSemaphore, QThread @@ -20,13 +19,21 @@ class TimeOutError(Exception): """An exception to raise when a timeouts expire""" +class _CustomQSemaphore(QSemaphore): + def tryAcquire(self, n, timeout=None): + if timeout is None: + timeout = -1 + timeout *= 1000 + return super().tryAcquire(n, timeout) + + class QtBasedQueue: """A Qt-based clone of queue.Queue.""" def __init__(self): self._items = [] self._mutex = QMutex() - self._semafore = QSemaphore() + self._semafore = _CustomQSemaphore() def put(self, item): self._mutex.lock() @@ -35,9 +42,6 @@ def put(self, item): self._semafore.release() def get(self, timeout=None): - if timeout is None: - timeout = -1 - timeout *= 1000 if not self._semafore.tryAcquire(1, timeout): raise TimeOutError() self._mutex.lock() @@ -50,26 +54,42 @@ class QtBasedFuture: """A Qt-based clone of concurrent.futures.Future.""" def __init__(self): - self._result_queue = QtBasedQueue() - self._exception_queue = QtBasedQueue() + self._semafore = _CustomQSemaphore() + self._done = False + self._result = None + self._exception = None + self._done_callbacks = [] def set_result(self, result): - self._exception_queue.put(None) - self._result_queue.put(result) + self._result = result + self._done = True + self._semafore.release() + for callback in self._done_callbacks: + callback(self) + self._done_callbacks = [] def set_exception(self, exc): - self._exception_queue.put(exc) - self._result_queue.put(None) + self._exception = exc + self._done = True + self._semafore.release() def result(self, timeout=None): - result = self._result_queue.get(timeout=timeout) - exc = self.exception(timeout=0) - if exc is not None: - raise exc - return result + if not self._done and not self._semafore.tryAcquire(1, timeout): + raise TimeOutError() + if self._exception is not None: + raise self._exception + return self._result def exception(self, timeout=None): - return self._exception_queue.get(timeout=timeout) + if not self._done and not self._semafore.tryAcquire(1, timeout): + raise TimeOutError() + return self._exception + + def add_done_callback(self, callback): + if self._done: + callback(self) + return + self._done_callbacks.append(callback) class QtBasedThread(QThread): @@ -119,11 +139,7 @@ def _do_work(self): if self._shutdown: break future, fn, args, kwargs = request - try: - result = fn(*args, **kwargs) - future.set_result(result) - except Exception as exc: # pylint: disable=broad-except - future.set_exception(exc) + _set_future_result_and_exc(future, fn, *args, **kwargs) self._semafore.release() def shutdown(self): @@ -134,3 +150,21 @@ def shutdown(self): thread = self._threads.pop() thread.wait() thread.deleteLater() + + +class SynchronousExecutor: + def submit(self, fn, *args, **kwargs): + future = QtBasedFuture() + _set_future_result_and_exc(future, fn, *args, **kwargs) + return future + + def shutdown(self): + pass + + +def _set_future_result_and_exc(future, fn, *args, **kwargs): + try: + result = fn(*args, **kwargs) + future.set_result(result) + except Exception as exc: # pylint: disable=broad-except + future.set_exception(exc) diff --git a/spinetoolbox/resources_icons_rc.py b/spinetoolbox/resources_icons_rc.py index 3ffddd7aa..34b5ad9be 100644 --- a/spinetoolbox/resources_icons_rc.py +++ b/spinetoolbox/resources_icons_rc.py @@ -1,6 +1,7 @@ # Resource object code (Python 3) ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -27017,6 +27018,33 @@ style=\x22stroke-\ width:0.5\x22 />\x0d\x0a<\ /svg>\x0d\x0a\ +\x00\x00\x01\x8d\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 viewBox=\x22\ +0 0 512 512\x22>\ \x00\x00\x014\ <\ svg xmlns=\x22http:\ @@ -30168,6 +30196,41 @@ \x22\x0d\x0a style=\x22f\ ill:#ff0000\x22 />\x0d\ \x0a\x0d\x0a\ +\x00\x00\x02\x0f\ +<\ +svg xmlns=\x22http:\ +//www.w3.org/200\ +0/svg\x22 viewBox=\x22\ +0 0 384 512\x22>\ \x00\x00\x01\x1b\ <\ svg xmlns=\x22http:\ @@ -31804,6 +31867,10 @@ \x0bR\x1e\xf3\ \x00m\ \x00e\x00n\x00u\x00_\x00i\x00c\x00o\x00n\x00s\ +\x00\x09\ +\x08\x88\xa9\x07\ +\x00s\ +\x00h\x00a\x00r\x00e\x00.\x00s\x00v\x00g\ \x00\x12\ \x0an5\x93\ \x00p\ @@ -31975,6 +32042,11 @@ \x01\x80\x9cg\ \x00c\ \x00o\x00g\x00_\x00m\x00i\x00n\x00u\x00s\x00.\x00s\x00v\x00g\ +\x00\x12\ +\x03\xedqg\ +\x00b\ +\x00o\x00l\x00t\x00-\x00l\x00i\x00g\x00h\x00t\x00n\x00i\x00n\x00g\x00.\x00s\x00v\ +\x00g\ \x00\x09\ \x09(\xae'\ \x00t\ @@ -32056,9 +32128,9 @@ qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00X\ +\x00\x00\x00\x10\x00\x02\x00\x00\x00\x03\x00\x00\x00Z\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x19\x00\x00\x00\x09\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1a\x00\x00\x00\x09\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00 \x00\x02\x00\x00\x00\x03\x00\x00\x00\x06\ \x00\x00\x00\x00\x00\x00\x00\x00\ @@ -32076,21 +32148,21 @@ \x00\x00\x01\x8aE\xfa\xa6P\ \x00\x00\x01\xea\x00\x00\x00\x00\x00\x01\x00\x062\x92\ \x00\x00\x01\x8aE\xfa\xa6\xa3\ -\x00\x00\x04X\x00\x00\x00\x00\x00\x01\x00\x06\xa7<\ +\x00\x00\x04p\x00\x00\x00\x00\x00\x01\x00\x06\xa8\xcd\ \x00\x00\x01\x8aE\xfa\xa6P\ -\x00\x00\x04\xa0\x00\x00\x00\x00\x00\x01\x00\x06\xbc&\ +\x00\x00\x04\xb8\x00\x00\x00\x00\x00\x01\x00\x06\xbd\xb7\ \x00\x00\x01\x8aE\xfa\xa6\x82\ \x00\x00\x03D\x00\x00\x00\x00\x00\x01\x00\x06\x88\x1a\ \x00\x00\x01\x8aE\xfa\xa6\xa3\ -\x00\x00\x04\x80\x00\x00\x00\x00\x00\x01\x00\x06\xa9\x10\ +\x00\x00\x04\x98\x00\x00\x00\x00\x00\x01\x00\x06\xaa\xa1\ \x00\x00\x01\x8aE\xfa\xa6P\ -\x00\x00\x03\xf4\x00\x00\x00\x00\x00\x01\x00\x06\x98\x16\ +\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x06\x99\xa7\ \x00\x00\x01\x8aE\xfa\xa6\x82\ \x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06E\xb2\ \x00\x00\x01\x8aE\xfa\xa6P\ \x00\x00\x02h\x00\x00\x00\x00\x00\x01\x00\x06B\xab\ \x00\x00\x01\x8aE\xfa\xa6\xa3\ -\x00\x00\x03\xde\x00\x00\x00\x00\x00\x01\x00\x06\x95\xa7\ +\x00\x00\x03\xf6\x00\x00\x00\x00\x00\x01\x00\x06\x978\ \x00\x00\x01\x8aE\xfa\xa6_\ \x00\x00\x03^\x00\x00\x00\x00\x00\x01\x00\x06\x8a3\ \x00\x00\x01\x8aE\xfa\xa6P\ @@ -32102,19 +32174,21 @@ \x00\x00\x01\x8aE\xfa\xa6P\ \x00\x00\x01\xbe\x00\x00\x00\x00\x00\x01\x00\x060\xaf\ \x00\x00\x01\x8aE\xfa\xa6\x91\ +\x00\x00\x03\x98\x00\x00\x00\x00\x00\x01\x00\x06\x94o\ +\x00\x00\x01\x8e\xd1T\x9c\xe7\ \x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x06/\x9c\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x04\x0c\x00\x00\x00\x00\x00\x01\x00\x06\x9e\x7f\ +\x00\x00\x04$\x00\x00\x00\x00\x00\x01\x00\x06\xa0\x10\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x03\x98\x00\x02\x00\x00\x00\x09\x00\x00\x00O\ +\x00\x00\x03\xb0\x00\x02\x00\x00\x00\x09\x00\x00\x00Q\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x03~\x00\x02\x00\x00\x00-\x00\x00\x00\x22\ +\x00\x00\x03~\x00\x02\x00\x00\x00.\x00\x00\x00#\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x02\x0a\x00\x00\x00\x00\x00\x01\x00\x065G\ \x00\x00\x01\x8aE\xfa\xa6\xa3\ -\x00\x00\x04<\x00\x00\x00\x00\x00\x01\x00\x06\xa1\x98\ +\x00\x00\x04T\x00\x00\x00\x00\x00\x01\x00\x06\xa3)\ \x00\x00\x01\x8aE\xfa\xa6\xa3\ -\x00\x00\x03\xc2\x00\x00\x00\x00\x00\x01\x00\x06\x94o\ +\x00\x00\x03\xda\x00\x00\x00\x00\x00\x01\x00\x06\x96\x00\ \x00\x00\x01\x8aE\xfa\xa6P\ \x00\x00\x02\xda\x00\x00\x00\x00\x00\x01\x00\x06T\x05\ \x00\x00\x01\x8aE\xfa\xa6P\ @@ -32122,113 +32196,115 @@ \x00\x00\x01\x8aE\xfa\xa6\xa3\ \x00\x00\x03\x02\x00\x00\x00\x00\x00\x01\x00\x06`G\ \x00\x00\x01\x8aE\xfa\xa6P\ -\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x07\x05\xa0\ +\x00\x00\x02R\x00\x00\x00\x00\x00\x01\x00\x07\x071\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x0b\x18\x00\x00\x00\x00\x00\x01\x00\x07\xad\x12\ +\x00\x00\x0bZ\x00\x00\x00\x00\x00\x01\x00\x07\xb0\xb6\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x06\x18\x00\x00\x00\x00\x00\x01\x00\x06\xebN\ +\x00\x00\x060\x00\x00\x00\x00\x00\x01\x00\x06\xec\xdf\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x08\xba\x00\x00\x00\x00\x00\x01\x00\x07A\xae\ +\x00\x00\x08\xd2\x00\x00\x00\x00\x00\x01\x00\x07C?\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09\x16\x00\x00\x00\x00\x00\x01\x00\x07Vm\ +\x00\x00\x09X\x00\x00\x00\x00\x00\x01\x00\x07Z\x11\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x06\xba\x00\x00\x00\x00\x00\x01\x00\x07\x03D\ +\x00\x00\x06\xd2\x00\x00\x00\x00\x00\x01\x00\x07\x04\xd5\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x08>\x00\x00\x00\x00\x00\x01\x00\x07;\xea\ +\x00\x00\x08V\x00\x00\x00\x00\x00\x01\x00\x07={\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x08\xf2\x00\x00\x00\x00\x00\x01\x00\x07T\xab\ +\x00\x00\x094\x00\x00\x00\x00\x00\x01\x00\x07XO\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x0a2\x00\x00\x00\x00\x00\x01\x00\x07\x94\xae\ +\x00\x00\x0at\x00\x00\x00\x00\x00\x01\x00\x07\x98R\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x07F\x00\x00\x00\x00\x00\x01\x00\x07\x17\xb0\ +\x00\x00\x07^\x00\x00\x00\x00\x00\x01\x00\x07\x19A\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x06\xda\x00\x00\x00\x00\x00\x01\x00\x07\x08\xe0\ +\x00\x00\x06\xf2\x00\x00\x00\x00\x00\x01\x00\x07\x0aq\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x08\x1e\x00\x00\x00\x00\x00\x01\x00\x07\x22\xda\ +\x00\x00\x08\xf2\x00\x00\x00\x00\x00\x01\x00\x07U\x1d\ +\x00\x00\x01\x8e\xd1T\x9c\xe6\ +\x00\x00\x086\x00\x00\x00\x00\x00\x01\x00\x07$k\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x08`\x00\x00\x00\x00\x00\x01\x00\x07=\xf7\ +\x00\x00\x08x\x00\x00\x00\x00\x00\x01\x00\x07?\x88\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x07\x98\x00\x00\x00\x00\x00\x01\x00\x07\x1d\xab\ +\x00\x00\x07\xb0\x00\x00\x00\x00\x00\x01\x00\x07\x1f<\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x08\x94\x00\x00\x00\x00\x00\x01\x00\x07?S\ +\x00\x00\x08\xac\x00\x00\x00\x00\x00\x01\x00\x07@\xe4\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x06>\x00\x00\x00\x00\x00\x01\x00\x06\xedW\ +\x00\x00\x06V\x00\x00\x00\x00\x00\x01\x00\x06\xee\xe8\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09d\x00\x00\x00\x00\x00\x01\x00\x07r\xb2\ +\x00\x00\x09\xa6\x00\x00\x00\x00\x00\x01\x00\x07vV\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x07^\x00\x00\x00\x00\x00\x01\x00\x07\x19\xa3\ +\x00\x00\x07v\x00\x00\x00\x00\x00\x01\x00\x07\x1b4\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x0a\x86\x00\x00\x00\x00\x00\x01\x00\x07\x97y\ +\x00\x00\x0a\xc8\x00\x00\x00\x00\x00\x01\x00\x07\x9b\x1d\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x06\x02\x00\x00\x00\x00\x00\x01\x00\x06\xe8\xdf\ +\x00\x00\x06\x1a\x00\x00\x00\x00\x00\x01\x00\x06\xeap\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x06\x98\x00\x00\x00\x00\x00\x01\x00\x06\xf5g\ +\x00\x00\x06\xb0\x00\x00\x00\x00\x00\x01\x00\x06\xf6\xf8\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x07\xfa\x00\x00\x00\x00\x00\x01\x00\x07!x\ +\x00\x00\x08\x12\x00\x00\x00\x00\x00\x01\x00\x07#\x09\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x06\xfe\x00\x00\x00\x00\x00\x01\x00\x07\x13\x93\ +\x00\x00\x07\x16\x00\x00\x00\x00\x00\x01\x00\x07\x15$\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x0a\xd0\x00\x00\x00\x00\x00\x01\x00\x07\x9b;\ +\x00\x00\x0b\x12\x00\x00\x00\x00\x00\x01\x00\x07\x9e\xdf\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x0aV\x00\x00\x00\x00\x00\x01\x00\x07\x96t\ +\x00\x00\x0a\x98\x00\x00\x00\x00\x00\x01\x00\x07\x9a\x18\ \x00\x00\x01\x8aE\xfa\xa6\x81\ -\x00\x00\x05\xe6\x00\x00\x00\x00\x00\x01\x00\x06\xe5\xba\ +\x00\x00\x05\xfe\x00\x00\x00\x00\x00\x01\x00\x06\xe7K\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x07\x80\x00\x00\x00\x00\x00\x01\x00\x07\x1a\xd0\ +\x00\x00\x07\x98\x00\x00\x00\x00\x00\x01\x00\x07\x1ca\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09N\x00\x00\x00\x00\x00\x01\x00\x07p\xff\ +\x00\x00\x09\x90\x00\x00\x00\x00\x00\x01\x00\x07t\xa3\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x0a\xf8\x00\x00\x00\x00\x00\x01\x00\x07\x9d\x96\ +\x00\x00\x0b:\x00\x00\x00\x00\x00\x01\x00\x07\xa1:\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09\xb8\x00\x00\x00\x00\x00\x01\x00\x07\x82E\ +\x00\x00\x09\xfa\x00\x00\x00\x00\x00\x01\x00\x07\x85\xe9\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x08\xda\x00\x00\x00\x00\x00\x01\x00\x07S\x8c\ +\x00\x00\x09\x1c\x00\x00\x00\x00\x00\x01\x00\x07W0\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x098\x00\x00\x00\x00\x00\x01\x00\x07d\xdb\ +\x00\x00\x09z\x00\x00\x00\x00\x00\x01\x00\x07h\x7f\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x06\x84\x00\x00\x00\x00\x00\x01\x00\x06\xf0\xcd\ +\x00\x00\x06\x9c\x00\x00\x00\x00\x00\x01\x00\x06\xf2^\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09\xe4\x00\x00\x00\x00\x00\x01\x00\x07\x85\x07\ +\x00\x00\x0a&\x00\x00\x00\x00\x00\x01\x00\x07\x88\xab\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x09\x88\x00\x00\x00\x00\x00\x01\x00\x07\x80\xaf\ +\x00\x00\x09\xca\x00\x00\x00\x00\x00\x01\x00\x07\x84S\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x070\x00\x00\x00\x00\x00\x01\x00\x07\x15a\ +\x00\x00\x07H\x00\x00\x00\x00\x00\x01\x00\x07\x16\xf2\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x09\xfc\x00\x00\x00\x00\x00\x01\x00\x07\x87Q\ +\x00\x00\x0a>\x00\x00\x00\x00\x00\x01\x00\x07\x8a\xf5\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x06n\x00\x00\x00\x00\x00\x01\x00\x06\xef#\ +\x00\x00\x06\x86\x00\x00\x00\x00\x00\x01\x00\x06\xf0\xb4\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x0a\x14\x00\x00\x00\x00\x00\x01\x00\x07\x88\xb7\ +\x00\x00\x0aV\x00\x00\x00\x00\x00\x01\x00\x07\x8c[\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x07\xcc\x00\x01\x00\x00\x00\x01\x00\x07\x1f\x00\ +\x00\x00\x07\xe4\x00\x01\x00\x00\x00\x01\x00\x07 \x91\ \x00\x00\x01\x8aE\xfa\xa6\x82\ -\x00\x00\x07\xe6\x00\x00\x00\x00\x00\x01\x00\x07\x1f\xda\ +\x00\x00\x07\xfe\x00\x00\x00\x00\x00\x01\x00\x07!k\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x0a\xaa\x00\x00\x00\x00\x00\x01\x00\x07\x99\x5c\ +\x00\x00\x0a\xec\x00\x00\x00\x00\x00\x01\x00\x07\x9d\x00\ \x00\x00\x01\x8aE\xfa\xa6p\ -\x00\x00\x04\xd8\x00\x00\x00\x00\x00\x01\x00\x07\x06\xaf\ +\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x07\x08@\ \x00\x00\x01\x8aE\xfa\xa6q\ -\x00\x00\x05@\x00\x00\x00\x00\x00\x01\x00\x071X\ +\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x072\xe9\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x05l\x00\x00\x00\x00\x00\x01\x00\x07f/\ +\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x07i\xd3\ \x00\x00\x01\x8aE\xfa\xa6`\ -\x00\x00\x05\x98\x00\x00\x00\x00\x00\x01\x00\x06\xe1\xa9\ +\x00\x00\x05\xb0\x00\x00\x00\x00\x00\x01\x00\x06\xe3:\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x04\xf6\x00\x00\x00\x00\x00\x01\x00\x06\xc90\ +\x00\x00\x05\x0e\x00\x00\x00\x00\x00\x01\x00\x06\xca\xc1\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06\xc5\x1e\ +\x00\x00\x02\x80\x00\x00\x00\x00\x00\x01\x00\x06\xc6\xaf\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x04\xb6\x00\x00\x00\x00\x00\x01\x00\x06\xc3\x0b\ +\x00\x00\x04\xce\x00\x00\x00\x00\x00\x01\x00\x06\xc4\x9c\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x05\xca\x00\x00\x00\x00\x00\x01\x00\x06\xe3{\ +\x00\x00\x05\xe2\x00\x00\x00\x00\x00\x01\x00\x06\xe5\x0c\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x05\x10\x00\x00\x00\x00\x00\x01\x00\x06\xcb\xba\ +\x00\x00\x05(\x00\x00\x00\x00\x00\x01\x00\x06\xcdK\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x04\xd8\x00\x00\x00\x00\x00\x01\x00\x06\xc6\xff\ +\x00\x00\x04\xf0\x00\x00\x00\x00\x00\x01\x00\x06\xc8\x90\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x05@\x00\x00\x00\x00\x00\x01\x00\x06\xcem\ +\x00\x00\x05X\x00\x00\x00\x00\x00\x01\x00\x06\xcf\xfe\ \x00\x00\x01\x8aE\xfa\xa6\x92\ -\x00\x00\x05l\x00\x00\x00\x00\x00\x01\x00\x06\xd8'\ +\x00\x00\x05\x84\x00\x00\x00\x00\x00\x01\x00\x06\xd9\xb8\ \x00\x00\x01\x8aE\xfa\xa6\x92\ \x00\x00\x01&\x00\x01\x00\x00\x00\x01\x00\x03\x04\x82\ \x00\x00\x01\x8aE\xfa\xa6P\ diff --git a/spinetoolbox/resources_logos_rc.py b/spinetoolbox/resources_logos_rc.py index a72f0e616..a4342862e 100644 --- a/spinetoolbox/resources_logos_rc.py +++ b/spinetoolbox/resources_logos_rc.py @@ -1,6 +1,7 @@ # Resource object code (Python 3) ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8709,15 +8710,15 @@ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x9a\x00\x00\x00\x00\x00\x01\x00\x00\x94\x9a\ -\x00\x00\x01y\x13P\x1f\xc5\ +\x00\x00\x01\x8aE\xfa\xa6?\ \x00\x00\x00p\x00\x00\x00\x00\x00\x01\x00\x00\x86\x0d\ -\x00\x00\x01y\x13P\x1f\xc4\ +\x00\x00\x01\x8aE\xfa\xa6/\ \x00\x00\x00 \x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01y\x13P\x1f\xc3\ +\x00\x00\x01\x8aE\xfa\xa6/\ \x00\x00\x00\xc6\x00\x00\x00\x00\x00\x01\x00\x00\xd6\x82\ -\x00\x00\x01y#!\xbfm\ +\x00\x00\x01\x8aE\xfa\xa6?\ \x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00E\xca\ -\x00\x00\x01y\x13P\x1f\xc3\ +\x00\x00\x01\x8aE\xfa\xa6/\ " def qInitResources(): diff --git a/spinetoolbox/server/__init__.py b/spinetoolbox/server/__init__.py index 23c78291f..5d2780281 100644 --- a/spinetoolbox/server/__init__.py +++ b/spinetoolbox/server/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Toolbox. # Spine Engine 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 @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Package for handling the client part of executing projects on Spine Engine Server. -""" +"""Package for handling the client part of executing projects on Spine Engine Server.""" diff --git a/spinetoolbox/server/engine_client.py b/spinetoolbox/server/engine_client.py index fab6583db..018868f35 100644 --- a/spinetoolbox/server/engine_client.py +++ b/spinetoolbox/server/engine_client.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Toolbox. # Spine Engine 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Client for exchanging messages between the toolbox and the Spine Engine Server. -""" - +"""Client for exchanging messages between the toolbox and the Spine Engine Server.""" import os import time import random @@ -56,9 +54,9 @@ def __init__(self, host, port, sec_model, sec_folder, ping=True): # implementation below based on https://github.com/zeromq/pyzmq/blob/main/examples/security/stonehouse.py # prepare folders base_dir = sec_folder - secret_keys_dir = os.path.join(base_dir, 'private_keys') - keys_dir = os.path.join(base_dir, 'certificates') - public_keys_dir = os.path.join(base_dir, 'public_keys') + secret_keys_dir = os.path.join(base_dir, "private_keys") + keys_dir = os.path.join(base_dir, "certificates") + public_keys_dir = os.path.join(base_dir, "public_keys") # We need two certificates, one for the client and one for # the server. The client must know the server's public key # to make a CURVE connection. @@ -190,15 +188,15 @@ def stop_execution(self, job_id): response_server_message = ServerMessage.parse(response[1]) return response_server_message.getData() - def answer_prompt(self, job_id, item_name, accepted): + def answer_prompt(self, job_id, prompter_id, answer): """Sends a request to answer a prompt from the DAG that is managed by this client. Args: job_id (str): Job Id on server to stop - item_name (str) - accepted (Bool) + prompter_id (int) + answer """ - req = ServerMessage("answer_prompt", job_id, json.dumps((item_name, True)), None) + req = ServerMessage("answer_prompt", job_id, json.dumps((prompter_id, answer)), None) self.socket.send_multipart([req.to_bytes()]) def download_files(self, q): diff --git a/spinetoolbox/spine_db_commands.py b/spinetoolbox/spine_db_commands.py index 7a9eacbd7..014002645 100644 --- a/spinetoolbox/spine_db_commands.py +++ b/spinetoolbox/spine_db_commands.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,22 +10,12 @@ # this program. If not, see . ###################################################################################################################### -""" -QUndoCommand subclasses for modifying the db. -""" - +"""QUndoCommand subclasses for modifying the db.""" import time -import uuid -from collections import defaultdict - from PySide6.QtGui import QUndoCommand, QUndoStack class AgedUndoStack(QUndoStack): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._command_backup = [] - @property def redo_age(self): if self.canRedo(): @@ -37,79 +28,47 @@ def undo_age(self): return self.command(self.index() - 1).age return -1 - def commands(self): - return [self.command(i) for i in range(self.index())] - - def push(self, cmd): - if self.cleanIndex() > self.index() and not self._command_backup: - # Pushing the command will delete all undone commands. - # We need to save all commands till the clean index. - self._command_backup = [self.command(i).clone() for i in range(self.cleanIndex())] - super().push(cmd) - - def commit(self): - if self._command_backup: - # Find index where backup and stack branch away - branching_idx = next( - (i for i in range(self.index()) if not self._command_backup[i].is_clone(self.command(i))), None - ) - # Undo all backup commands from last till branching index - for cmd in reversed(self._command_backup[branching_idx:]): - cmd.undo() - # Redo all commands from branching index till stack index - for i in range(branching_idx, self.index()): - self.command(i).redo() - return - if self.index() > self.cleanIndex(): - for i in range(self.cleanIndex(), self.index()): - self.command(i).redo() - elif self.index() < self.cleanIndex(): - for i in reversed(range(self.index(), self.cleanIndex())): - self.command(i).undo() - - def setClean(self): - self._command_backup = [] - super().setClean() - class AgedUndoCommand(QUndoCommand): - def __init__(self, parent=None): + def __init__(self, parent=None, identifier=-1): """ Args: parent (QUndoCommand, optional): The parent command, used for defining macros. """ super().__init__(parent=parent) self._age = -1 - self.identifier = uuid.uuid4() - - def clone(self): - """Clones the command. + self._id = identifier + self._buddies = [] + self.merged = False - Returns: - AgedUndoCommand: cloned command - """ - clone = self._do_clone() - clone.identifier = self.identifier - return clone - - def _do_clone(self): - """Clones the command. + def id(self): + """override""" + return self._id - Subclasses should reimplement this to clone their internal state. + def ours(self): + yield self + yield from self._buddies - Returns: - AgedUndoCommand: cloned command - """ - raise NotImplementedError() - - def is_clone(self, other): - return other.identifier == self.identifier + def mergeWith(self, command): + if not isinstance(command, AgedUndoCommand): + return False + self._buddies += [x for x in command.ours() if not x.isObsolete()] + command.merged = True + return True def redo(self): + if self.merged: + return super().redo() + for cmd in self._buddies: + cmd.redo() self._age = time.time() def undo(self): + if self.merged: + return + for cmd in reversed(self._buddies): + cmd.undo() super().undo() self._age = time.time() @@ -118,309 +77,164 @@ def age(self): return self._age -class SpineDBMacro(AgedUndoCommand): - """A command that just runs a series of SpineDBCommand's one after another, *waiting* for each one to finish - before starting the next.""" - - def __init__(self, cmd_iter, parent=None): - super().__init__(parent=parent) - self._cmd_iter = cmd_iter - self._reverse_cmd_iter = None - self._cmds = [] - self._completed_once = False - - def _do_clone(self): - clone = SpineDBMacro([]) - clone._cmd_iter = self._cmd_iter - clone._reverse_cmd_iter = self._reverse_cmd_iter - clone._cmds = self._cmds - clone._completed_once = self._completed_once - return clone - - def redo(self): - super().redo() - if self._completed_once: - self._cmd_iter = iter(self._cmds) - self._redo_next() - - def _redo_next(self): - child = next(self._cmd_iter, None) - if child is None: - self._completed_once = True - return - if not self._completed_once: - self._cmds.append(child) - child.redo_complete_callback = self._redo_next - child.redo() - - def undo(self): - super().undo() - self._reverse_cmd_iter = reversed(self._cmds) - self._undo_next() - - def _undo_next(self): - child = next(self._reverse_cmd_iter, None) - if child is None: - return - child.undo_complete_callback = self._undo_next - child.undo() - - class SpineDBCommand(AgedUndoCommand): """Base class for all commands that modify a Spine DB.""" - def __init__(self, db_mngr, db_map, parent=None): + def __init__(self, db_mngr, db_map, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance db_map (DiffDatabaseMapping): DiffDatabaseMapping instance - parent (QUndoCommand, optional): The parent command, used for defining macros. """ - super().__init__(parent=parent) + super().__init__(**kwargs) self.db_mngr = db_mngr self.db_map = db_map - self._done_once = False - self.redo_complete_callback = lambda: None - self.undo_complete_callback = lambda: None - - def handle_undo_complete(self, db_map_data): - """Calls the undo complete callback with the data from undo(). - - Subclasses need to pass this as the callback to the function that modifies the db in undo(). - - Args: - db_map_data (dict): mapping from database map to list of original cache items - """ - self.undo_complete_callback() - - def handle_redo_complete(self, db_map_data): - """Calls the redo complete callback with the data from redo(). - - Subclasses need to pass this as the callback to the function that modifies the db in redo(). - - Args: - db_map_data (dict): mapping from database map to list of cache items - """ - self.redo_complete_callback() - if self._done_once: - return - self._done_once = True - self._handle_first_redo_complete(db_map_data) - - def _handle_first_redo_complete(self, db_map_data): - """Reimplement in subclasses to do stuff with the data from running redo() the first time. - - Args: - db_map_data (dict): mapping from database map to list of original cache items - """ - raise NotImplementedError() class AddItemsCommand(SpineDBCommand): - _add_command_name = { - "object_class": "add object classes", - "object": "add objects", - "relationship_class": "add relationship classes", - "relationship": "add relationships", - "entity_group": "add entity groups", - "parameter_definition": "add parameter definitions", - "parameter_value": "add parameter values", - "parameter_value_list": "add parameter value lists", - "list_value": "add parameter value list values", - "alternative": "add alternative", - "scenario": "add scenario", - "scenario_alternative": "add scenario alternative", - "feature": "add feature", - "tool": "add tool", - "tool_feature": "add tool features", - "tool_feature_method": "add tool feature methods", - "metadata": "add metadata", - "entity_metadata": "add entity metadata", - "parameter_value_metadata": "add parameter value metadata", - } - - def __init__(self, db_mngr, db_map, data, item_type, parent=None, check=True): + def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance db_map (DiffDatabaseMapping): DiffDatabaseMapping instance data (list): list of dict-items to add item_type (str): the item type - parent (QUndoCommand, optional): The parent command, used for defining macros. """ - super().__init__(db_mngr, db_map, parent=parent) + super().__init__(db_mngr, db_map, **kwargs) if not data: self.setObsolete(True) - self.redo_db_map_data = {db_map: data} self.item_type = item_type - self.undo_db_map_ids = None - self._readd = False + self.redo_data = data + self.undo_ids = None self._check = check - self.setText(self._add_command_name.get(item_type, "add item") + f" to '{db_map.codename}'") - - def _do_clone(self): - clone = AddItemsCommand(self.db_mngr, self.db_map, [], self.item_type) - clone.redo_db_map_data = self.redo_db_map_data - clone.undo_db_map_ids = self.undo_db_map_ids - clone._readd = self._readd - clone._check = self._check - return clone + self.setText(f"add {item_type} items to {db_map.codename}") def redo(self): super().redo() - successful = self.db_mngr.add_items( - self.redo_db_map_data, - self.item_type, - readd=self._readd, - cascade=False, - check=self._check, - callback=self.handle_redo_complete, - ) - if not successful: + if self.undo_ids: + self.db_mngr.do_restore_items(self.db_map, self.item_type, self.undo_ids) + return + data = self.db_mngr.do_add_items(self.db_map, self.item_type, self.redo_data, check=self._check) + if not data: self.setObsolete(True) + return + self.undo_ids = {x["id"] for x in data} def undo(self): super().undo() - self.db_mngr.do_remove_items(self.item_type, self.undo_db_map_ids, callback=self.handle_undo_complete) - self._readd = True - - def _handle_first_redo_complete(self, db_map_data): - if self.db_map not in db_map_data: - self.setObsolete(True) - return - self.redo_db_map_data = {db_map: [x.deepcopy() for x in data] for db_map, data in db_map_data.items()} - self.undo_db_map_ids = {db_map: {x["id"] for x in data} for db_map, data in db_map_data.items()} + self.db_mngr.do_remove_items(self.db_map, self.item_type, self.undo_ids, check=False) class UpdateItemsCommand(SpineDBCommand): - _update_command_name = { - "object_class": "update object classes", - "object": "update objects", - "relationship_class": "update relationship classes", - "relationship": "update relationships", - "parameter_definition": "update parameter definitions", - "parameter_value": "update parameter values", - "parameter_value_list": "update parameter value lists", - "list_value": "update parameter value list values", - "alternative": "update alternatives", - "scenario": "update scenarios", - "scenario_alternative": "update scenario alternative", - "feature": "update features", - "tool": "update tools", - "tool_feature": "update tool features", - "tool_feature_method": "update tool feature methods", - "metadata": "update metadata", - "entity_metadata": "update entity metadata", - "parameter_value_metadata": "update parameter value metadata", - } - - def __init__(self, db_mngr, db_map, data, item_type, parent=None, check=True): + def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance db_map (DiffDatabaseMapping): DiffDatabaseMapping instance - data (list): list of dict-items to update item_type (str): the item type - parent (QUndoCommand, optional): The parent command, used for defining macros. + data (list): list of dict-items to update """ - super().__init__(db_mngr, db_map, parent=parent) + super().__init__(db_mngr, db_map, **kwargs) if not data: self.setObsolete(True) - undo_data = [self.db_mngr.get_item(self.db_map, item_type, item["id"]).copy() for item in data] - redo_data = [{**undo_item, **item} for undo_item, item in zip(undo_data, data)] - if undo_data == redo_data: - self.setObsolete(True) - self.redo_db_map_data = {db_map: redo_data} - self.undo_db_map_data = {db_map: undo_data} self.item_type = item_type + self.redo_data = data + self.undo_data = [self.db_mngr.get_item(self.db_map, item_type, item["id"])._asdict() for item in data] + if self.redo_data == self.undo_data: + self.setObsolete(True) self._check = check - self.setText(self._update_command_name.get(item_type, "update item") + f" in '{db_map.codename}'") - - def _do_clone(self): - clone = UpdateItemsCommand(self.db_mngr, self.db_map, [], self.item_type) - clone.redo_db_map_data = self.redo_db_map_data - clone.undo_db_map_data = self.undo_db_map_data - clone._check = self._check - return clone + self.setText(f"update {item_type} items in {db_map.codename}") def redo(self): super().redo() - successful = self.db_mngr.update_items( - self.redo_db_map_data, self.item_type, check=self._check, callback=self.handle_redo_complete - ) - if not successful: + self.redo_data = [ + x._asdict() + for x in self.db_mngr.do_update_items(self.db_map, self.item_type, self.redo_data, check=self._check) + ] + if not self.redo_data: self.setObsolete(True) + return + self._check = False def undo(self): super().undo() - self.db_mngr.update_items( - self.undo_db_map_data, self.item_type, check=False, callback=self.handle_undo_complete - ) + self.db_mngr.do_update_items(self.db_map, self.item_type, self.undo_data, check=False) + - def _handle_first_redo_complete(self, db_map_data): - if self.db_map not in db_map_data: +class AddUpdateItemsCommand(SpineDBCommand): + def __init__(self, db_mngr, db_map, item_type, data, check=True, **kwargs): + """ + Args: + db_mngr (SpineDBManager): SpineDBManager instance + db_map (DiffDatabaseMapping): DiffDatabaseMapping instance + item_type (str): the item type + data (list): list of dict-items to add-update + """ + super().__init__(db_mngr, db_map, **kwargs) + if not data: + self.setObsolete(True) + self.item_type = item_type + self.new_data = data + old_data = [x._asdict() for item in data if (x := self.db_map.get_item(item_type, **item))] + if self.new_data == old_data: self.setObsolete(True) + self.old_data = {x["id"]: x for x in old_data} + self.redo_restore_ids = None + self.redo_update_data = None + self.undo_remove_ids = None + self.undo_update_data = None + self.setText(f"update {item_type} items in {db_map.codename}") + + def redo(self): + super().redo() + if self.redo_restore_ids is None: + added, updated = self.db_mngr.do_add_update_items(self.db_map, self.item_type, self.new_data) + if not added and not updated: + self.setObsolete(True) + return + self.redo_restore_ids = {x["id"] for x in added} + self.redo_update_data = [x._asdict() for x in updated] + self.undo_remove_ids = {x["id"] for x in added} + self.undo_update_data = [self.old_data[id_] for id_ in {x["id"] for x in updated}] return - self.redo_db_map_data = db_map_data - self._check = False + if self.redo_restore_ids: + self.db_mngr.do_restore_items(self.db_map, self.item_type, self.redo_restore_ids) + if self.redo_update_data: + self.db_mngr.do_update_items(self.db_map, self.item_type, self.redo_update_data, check=False) + + def undo(self): + super().undo() + if self.undo_remove_ids: + self.db_mngr.do_remove_items(self.db_map, self.item_type, self.undo_remove_ids, check=False) + if self.undo_update_data: + self.db_mngr.do_update_items(self.db_map, self.item_type, self.undo_update_data, check=False) class RemoveItemsCommand(SpineDBCommand): - def __init__(self, db_mngr, db_map, ids, item_type, parent=None): + def __init__(self, db_mngr, db_map, item_type, ids, check=True, **kwargs): """ Args: db_mngr (SpineDBManager): SpineDBManager instance db_map (DiffDatabaseMapping): DiffDatabaseMapping instance - ids (set): set of ids to remove item_type (str): the item type - parent (QUndoCommand, optional): The parent command, used for defining macros. + ids (set): set of ids to remove """ - super().__init__(db_mngr, db_map, parent=parent) + super().__init__(db_mngr, db_map, **kwargs) if not ids: self.setObsolete(True) - self.redo_db_map_ids = {db_map: ids} - self.undo_data = {} self.item_type = item_type - self.setText(f"remove {item_type} items from '{db_map.codename}'") - - def _do_clone(self): - clone = RemoveItemsCommand(self.db_mngr, self.db_map, set(), self.item_type) - clone.redo_db_map_ids = self.redo_db_map_ids - clone.undo_data = self.undo_data - return clone + self.ids = ids + self._check = check + self.setText(f"remove {item_type} items from {db_map.codename}") def redo(self): super().redo() - self.db_mngr.do_remove_items( - self.item_type, - self.redo_db_map_ids, - callback=self.handle_redo_complete, - committing_callback=self._update_undo_data, - ) + items = self.db_mngr.do_remove_items(self.db_map, self.item_type, self.ids, check=self._check) + if not items: + self.setObsolete(True) + self.ids = {x["id"] for x in items} + self._check = False def undo(self): super().undo() - operations = list(self.undo_data.items()) - for item_type, items in operations[:-1]: - self.db_mngr.add_items({self.db_map: items}, item_type, readd=True) - item_type, items = operations[-1] - self.db_mngr.add_items({self.db_map: items}, item_type, readd=True, callback=self.handle_undo_complete) - - def _handle_first_redo_complete(self, db_map_data): - undo_data = defaultdict(list) - for db_map, data in db_map_data.items(): - if db_map is not self.db_map: - continue - for item in data: - undo_data[item.item_type].append(item) - self.undo_data = undo_data - - def _update_undo_data(self, db_map_data): - all_existing_ids = {item_type: {item["id"] for item in items} for item_type, items in self.undo_data.items()} - for db_map, data in db_map_data.items(): - if db_map is not self.db_map: - continue - for item in data: - existing_ids = all_existing_ids.get(item.item_type) - if existing_ids is None or item["id"] not in existing_ids: - self.undo_data[item.item_type].append(item) + self.db_mngr.do_restore_items(self.db_map, self.item_type, self.ids) diff --git a/spinetoolbox/spine_db_editor/__init__.py b/spinetoolbox/spine_db_editor/__init__.py index 5468070d5..bf113c065 100644 --- a/spinetoolbox/spine_db_editor/__init__.py +++ b/spinetoolbox/spine_db_editor/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -This subpackage contains GUI files for the Spine db editor. -""" +"""This subpackage contains GUI files for the Spine db editor.""" diff --git a/spinetoolbox/spine_db_editor/graphics_items.py b/spinetoolbox/spine_db_editor/graphics_items.py index 5ee5dc29c..9e309ce7c 100644 --- a/spinetoolbox/spine_db_editor/graphics_items.py +++ b/spinetoolbox/spine_db_editor/graphics_items.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for drawing graphics items on graph view's QGraphicsScene. -""" -from PySide6.QtCore import Qt, Signal, Slot, QLineF +"""Classes for drawing graphics items on graph view's QGraphicsScene.""" +from enum import Enum, auto +from PySide6.QtCore import Qt, Signal, Slot, QLineF, QRectF, QPointF, QObject, QByteArray from PySide6.QtSvgWidgets import QGraphicsSvgItem from PySide6.QtWidgets import ( QGraphicsItem, @@ -24,45 +24,14 @@ QApplication, QMenu, ) -from PySide6.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication, QAction -from matplotlib.figure import Figure -from matplotlib.backends.backend_qt5agg import FigureCanvas # pylint: disable=no-name-in-module - -from spinetoolbox.helpers import DB_ITEM_SEPARATOR +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QPen, QBrush, QPainterPath, QPalette, QGuiApplication, QAction, QColor +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, color_from_index from spinetoolbox.widgets.custom_qwidgets import TitleWidgetAction -def make_figure_graphics_item(scene, z=0, static=True): - """Creates a FigureCanvas and adds it to the given scene. - Used for creating heatmaps and associated colorbars. - - Args: - scene (QGraphicsScene) - z (int, optional): z value. Defaults to 0. - static (bool, optional): if True (the default) the figure canvas is not movable - - Returns: - QGraphicsProxyWidget: the graphics item that represents the canvas - Figure: the figure in the canvas - """ - figure = Figure(tight_layout={"pad": 0}) - axes = figure.gca(xmargin=0, ymargin=0, frame_on=None) - axes.get_xaxis().set_visible(False) - axes.get_yaxis().set_visible(False) - canvas = FigureCanvas(figure) - if static: - proxy_widget = scene.addWidget(canvas) - proxy_widget.setAcceptedMouseButtons(Qt.NoButton) - else: - proxy_widget = scene.addWidget(canvas, Qt.Window) - proxy_widget.setZValue(z) - return proxy_widget, figure - - class EntityItem(QGraphicsRectItem): - """Base class for ObjectItem and RelationshipItem.""" - - def __init__(self, spine_db_editor, x, y, extent, db_map_ids): + def __init__(self, spine_db_editor, x, y, extent, db_map_ids, offset=None): """ Args: spine_db_editor (SpineDBEditor): 'owner' @@ -74,22 +43,23 @@ def __init__(self, spine_db_editor, x, y, extent, db_map_ids): super().__init__() self._spine_db_editor = spine_db_editor self.db_mngr = spine_db_editor.db_mngr + self._given_extent = extent self._db_map_ids = db_map_ids + self._offset = offset + self._dx = self._dy = 0 self._removed_db_map_ids = () - self.arc_items = list() - self._extent = extent - self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent) + self.arc_items = [] + self._circle_item = QGraphicsEllipseItem(self) + self._circle_item.setPen(Qt.NoPen) + self.set_pos(x, y) self.setPen(Qt.NoPen) self._svg_item = QGraphicsSvgItem(self) self._svg_item.setZValue(100) self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector self._renderer = None - self.refresh_icon() - self.setPos(x, y) self._moved_on_scene = False self._bg = None - self._bg_brush = Qt.NoBrush - self._init_bg() + self._bg_brush = None self.setZValue(0) self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=True) self.setFlag(QGraphicsItem.ItemIsMovable, enabled=True) @@ -98,17 +68,27 @@ def __init__(self, spine_db_editor, x, y, extent, db_map_ids): self.setAcceptHoverEvents(True) self.setCursor(Qt.ArrowCursor) self.setToolTip(self._make_tool_tip()) - self._highligh_color = Qt.transparent - - def _make_tool_tip(self): - raise NotImplementedError() - - def default_parameter_data(self): - raise NotImplementedError() + self._highlight_color = Qt.transparent + self._db_map_entity_class_lists = {} + self.label_item = EntityLabelItem(self) + self.label_item.setVisible(not self.has_dimensions) + self.setZValue(0.5 if not self.has_dimensions else 0.25) + self._extent = None + self.set_up() + + def clone(self): + return type(self)( + self._spine_db_editor, + self.pos().x(), + self.pos().y(), + self._given_extent, + self._db_map_ids, + offset=self._offset, + ) @property - def entity_type(self): - raise NotImplementedError() + def has_dimensions(self): + return bool(self.element_id_list(self.first_db_map)) @property def db_map_ids(self): @@ -119,23 +99,46 @@ def original_db_map_ids(self): return self._db_map_ids @property - def entity_class_type(self): - return {"relationship": "relationship_class", "object": "object_class"}[self.entity_type] - - @property - def entity_name(self): - return self.db_mngr.get_item(self.first_db_map, self.entity_type, self.first_id).get("name", "") + def name(self): + return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("name", "") @property def first_entity_class_id(self): - return self.db_mngr.get_item(self.first_db_map, self.entity_type, self.first_id).get("class_id") + return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("class_id") @property def entity_class_name(self): - return self.db_mngr.get_item(self.first_db_map, self.entity_class_type, self.first_entity_class_id).get( - "name", "" + return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get("name", "") + + @property + def dimension_id_list(self): + # FIXME: is this used? + return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get( + "dimension_id_list", () + ) + + @property + def dimension_name_list(self): + return self.db_mngr.get_item(self.first_db_map, "entity_class", self.first_entity_class_id).get( + "dimension_name_list", () ) + @property + def byname(self): + return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("entity_byname", ()) + + @property + def element_name_list(self): + return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_name_list", ()) + + def element_id_list(self, db_map): + return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("element_id_list", ()) + + @property + def element_byname_list(self): + # NOTE: Needed by EditEntitiesDialog + return self.db_mngr.get_item(self.first_db_map, "entity", self.first_id).get("element_byname_list", ()) + @property def first_db_map_id(self): return next(iter(self.db_map_ids), (None, None)) @@ -150,7 +153,7 @@ def first_db_map(self): @property def display_data(self): - return self.entity_name + return self.name @property def display_database(self): @@ -161,59 +164,225 @@ def db_maps(self): return list(db_map for db_map, _id in self.db_map_ids) def entity_class_id(self, db_map): - return self.db_mngr.get_item(db_map, self.entity_type, self.entity_id(db_map)).get("class_id") + return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)).get("class_id") + + def entity_class_ids(self, db_map): + return {self.entity_class_id(db_map)} | { + x["superclass_id"] + for x in db_map.get_items("superclass_subclass", subclass_id=self.entity_class_id(db_map)) + } def entity_id(self, db_map): return dict(self.db_map_ids).get(db_map) def db_map_data(self, db_map): - # NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog - return self.db_mngr.get_item(db_map, self.entity_type, self.entity_id(db_map)) + # NOTE: Needed by EditEntitiesDialog + return self.db_mngr.get_item(db_map, "entity", self.entity_id(db_map)) def db_map_id(self, db_map): - # NOTE: Needed by EditObjectsDialog and EditRelationshipsDialog + # NOTE: Needed by EditEntitiesDialog return self.entity_id(db_map) + def db_items(self, db_map): + for db_map_, id_ in self.db_map_ids: + if db_map_ == db_map: + yield dict(class_id=self.entity_class_id(db_map), id=id_) + def boundingRect(self): return super().boundingRect() | self.childrenBoundingRect() - def moveBy(self, dx, dy): - super().moveBy(dx, dy) + def set_pos(self, x, y): + x, y = self._snap(x, y) + self.setPos(x, y) self.update_arcs_line() - def _init_bg(self): - self._bg = QGraphicsRectItem(self.boundingRect(), self) - self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True) + def move_by(self, dx, dy): + self._dx += dx + self._dy += dy + dx, dy = self._snap(self._dx, self._dy) + if dx == dy == 0: + return + self.moveBy(dx, dy) + self._dx -= dx + self._dy -= dy + self.update_arcs_line() + ent_items = {arc_item.ent_item for arc_item in self.arc_items} + for ent_item in ent_items: + ent_item.update_entity_pos() + + def _snap(self, x, y): + if self._spine_db_editor.qsettings.value("appSettings/snapEntities", defaultValue="false") != "true": + return (x, y) + grid_size = self._given_extent + x = round(x / grid_size) * grid_size + y = round(y / grid_size) * grid_size + return (x, y) + + def has_unique_key(self): + """Returns whether or not the item still has a single key in all the databases it represents. + + Returns: + bool + """ + db_map_ids_by_key = {} + for db_map_id in self.db_map_ids: + key = self._spine_db_editor.get_entity_key(db_map_id) + db_map_ids_by_key.setdefault(key, []).append(db_map_id) + if len(db_map_ids_by_key) == 1: + return True + first_key = next(iter(db_map_ids_by_key)) + self._db_map_ids = tuple(db_map_ids_by_key[first_key]) + return False + + def _get_name(self): + for db_map, id_ in self.db_map_ids: + name = self._spine_db_editor.get_item_name(db_map, id_) + if isinstance(name, str): + return name + + def _get_prop(self, getter, index): + values = {getter(db_map, id_, index) for db_map, id_ in self.db_map_ids} + values.discard(None) + if not values: + return None + values.discard(self._spine_db_editor.NOT_SPECIFIED) + if not values: + return self._spine_db_editor.NOT_SPECIFIED + return next(iter(values)) + + def _get_color(self, index=None): + color = self._get_prop(self._spine_db_editor.get_item_color, index) + if color in (None, self._spine_db_editor.NOT_SPECIFIED): + return color + min_val, val, max_val = color + count = max(1, max_val - min_val) + k = val - min_val + return color_from_index(k, count) + + def _get_arc_width(self, index=None): + arc_width = self._get_prop(self._spine_db_editor.get_arc_width, index) + if arc_width in (None, self._spine_db_editor.NOT_SPECIFIED): + return arc_width + min_val, val, max_val = arc_width + range_ = max_val - min_val + if range_ == 0: + return None + if val > 0: + return val / max_val, 1 + return val / min_val, -1 + + def _get_vertex_radius(self, index=None): + vertex_radius = self._get_prop(self._spine_db_editor.get_vertex_radius, index) + if vertex_radius in (None, self._spine_db_editor.NOT_SPECIFIED): + return None + min_val, val, max_val = vertex_radius + range_ = max_val - min_val + if range_ == 0: + return 0 + return (val - min_val) / range_ + + def _has_name(self): + return True + + def set_up(self): + if self._has_name(): + name = self._get_name() + if not name: + self.label_item.hide() + self._extent = 0.2 * self._given_extent + else: + if not self.has_dimensions: + self.label_item.show() + self.label_item.setPlainText(name) + self._extent = self._given_extent + else: + self.label_item.hide() + self._extent = 0.5 * self._given_extent + else: + self.label_item.hide() + self._extent = self._given_extent + self.setRect(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent) + self._update_bg() + self.refresh_icon() + self.update_entity_pos() + + def update_props(self, index): + color = self._get_color(index) + arc_width = self._get_arc_width(index) + vertex_radius = self._get_vertex_radius(index) + self._update_renderer(color, resize=True) + self._update_arcs(color, arc_width) + self._update_circle(color, vertex_radius) + + def _update_bg(self): + bg_rect = QRectF(-0.5 * self._extent, -0.5 * self._extent, self._extent, self._extent) + if self._bg is not None: + self._bg.setRect(bg_rect) + self._bg.prepareGeometryChange() + self._bg.update() + return + if not self.has_dimensions: + self._bg = QGraphicsRectItem(bg_rect, self) + self._bg_brush = Qt.NoBrush + else: + self._bg = QGraphicsEllipseItem(bg_rect, self) + self._bg_brush = QGuiApplication.palette().button() pen = self._bg.pen() pen.setColor(Qt.transparent) self._bg.setPen(pen) + self._bg.setFlag(QGraphicsItem.ItemStacksBehindParent, enabled=True) def refresh_icon(self): """Refreshes the icon.""" - renderer = self.db_mngr.entity_class_renderer( - self.first_db_map, self.entity_class_type, self.first_entity_class_id - ) - self._set_renderer(renderer) - - def _set_renderer(self, renderer): - self._renderer = renderer - self._svg_item.setSharedRenderer(renderer) - size = renderer.defaultSize() + color = self._get_color() + self._update_renderer(color) + + def _update_renderer(self, color, resize=True): + if color is self._spine_db_editor.NOT_SPECIFIED: + color = color_from_index(0, 1, value=0) + self._renderer = self.db_mngr.entity_class_renderer(self.first_db_map, self.first_entity_class_id, color=color) + self._install_renderer() + + def _install_renderer(self, resize=True): + self._svg_item.setSharedRenderer(self._renderer) + if not resize: + return + size = self._renderer.defaultSize() scale = self._extent / max(size.width(), size.height()) self._svg_item.setScale(scale) rect = self._svg_item.boundingRect() self._svg_item.setTransformOriginPoint(rect.center()) self._svg_item.setPos(-rect.center()) + def _make_tool_tip(self): + if not self.first_id: + return None + return ( + f"""

{self.entity_class_name}
""" + f"""{DB_ITEM_SEPARATOR.join(self.byname)}
""" + f"""@{self.display_database}

""" + ) + + def default_parameter_data(self): + """Return data to put as default in a parameter table when this item is selected.""" + if not self.db_map_ids: + return {} + return dict( + entity_class_name=self.entity_class_name, + entity_byname=DB_ITEM_SEPARATOR.join(self.byname), + database=self.first_db_map.codename, + ) + def shape(self): """Returns a shape containing the entire bounding rect, to work better with icon transparency.""" path = QPainterPath() path.setFillRule(Qt.WindingFill) path.addRect(self._bg.boundingRect()) + path.addPolygon(self.label_item.mapToItem(self, self.label_item.boundingRect())) return path - def set_highligh_color(self, color): - self._highligh_color = color + def set_highlight_color(self, color): + self._highlight_color = color def paint(self, painter, option, widget=None): """Shows or hides the selection halo.""" @@ -223,8 +392,8 @@ def paint(self, painter, option, widget=None): else: self._paint_as_deselected() pen = self._bg.pen() - pen.setColor(self._highligh_color) - width = 10 / self.scale() + pen.setColor(self._highlight_color) + width = max(1, 10 / self.scale()) pen.setWidth(width) self._bg.setPen(pen) super().paint(painter, option, widget) @@ -242,7 +411,34 @@ def add_arc_item(self, arc_item): arc_item (ArcItem) """ self.arc_items.append(arc_item) - arc_item.update_line() + self._rotate_svg_item() + self.update_entity_pos() + + def update_entity_pos(self): + for arc_item in self.arc_items: + arc_item.ent_item.do_update_entity_pos() + self.do_update_entity_pos() + + def do_update_entity_pos(self): + el_items = sorted( + (arc_item.el_item for arc_item in self.arc_items if arc_item.el_item is not self), + key=lambda x: x.entity_id(x.first_db_map) or 0, + ) + dim_count = len(el_items) + if not dim_count: + return + new_pos_x = sum(el_item.pos().x() for el_item in el_items) / dim_count + new_pos_y = sum(el_item.pos().y() for el_item in el_items) / dim_count + offset = self._offset.value() if self._offset is not None else None + if offset: + el_item = el_items[0] + line = QLineF(QPointF(new_pos_x, new_pos_y), el_item.pos()).normalVector() + if offset < 0: + line.setAngle(line.angle() + 180) + line.setLength(3 * abs(offset) * self._extent) + new_pos_x, new_pos_y = line.x2(), line.y2() + self.setPos(new_pos_x, new_pos_y) + self.update_arcs_line() def apply_zoom(self, factor): """Applies zoom. @@ -250,8 +446,7 @@ def apply_zoom(self, factor): Args: factor (float): The zoom factor. """ - if factor > 1: - factor = 1 + factor = min(factor, 1) self.setScale(factor) def apply_rotation(self, angle, center): @@ -263,11 +458,8 @@ def apply_rotation(self, angle, center): """ line = QLineF(center, self.pos()) line.setAngle(line.angle() + angle) - self.setPos(line.p2()) - self.update_arcs_line() - - def block_move_by(self, dx, dy): - self.moveBy(dx, dy) + pos = line.p2() + self.set_pos(pos.x(), pos.y()) def mouseMoveEvent(self, event): """Moves the item and all connected arcs. @@ -278,20 +470,48 @@ def mouseMoveEvent(self, event): if event.buttons() & Qt.LeftButton == 0: super().mouseMoveEvent(event) return - move_by = event.scenePos() - event.lastScenePos() + delta = event.scenePos() - event.lastScenePos() # Move selected items together for item in self.scene().selectedItems(): if isinstance(item, (EntityItem)): - item.block_move_by(move_by.x(), move_by.y()) + item.move_by(delta.x(), delta.y()) def update_arcs_line(self): """Moves arc items.""" for item in self.arc_items: item.update_line() + color = self._get_color() + arc_width = self._get_arc_width() + self._update_arcs(color, arc_width) + + def _update_arcs(self, color, arc_width): + if color not in (None, self._spine_db_editor.NOT_SPECIFIED): + for item in self.arc_items: + item.update_color(color) + if arc_width not in (None, self._spine_db_editor.NOT_SPECIFIED): + width, sign = arc_width + factor = 0.75 * (0.5 + 0.5 * width) * self._extent + switched = False + for item in self.arc_items: + item.apply_value(factor, sign) + if not switched: + switched = True + sign = -sign + + def _update_circle(self, color, vertex_radius): + if color is self._spine_db_editor.NOT_SPECIFIED: + color = color_from_index(0, 1, value=0) + else: + color = QColor(color) + circle_extent = 2 * (0.5 + 0.5 * vertex_radius) * self._extent if vertex_radius is not None else 0 + self._circle_item.setRect(-circle_extent / 2, -circle_extent / 2, circle_extent, circle_extent) + color.setAlphaF(0.5) + self._circle_item.setBrush(color) def itemChange(self, change, value): """ - Keeps track of item's movements on the scene. + Keeps track of item's movements on the scene. Rotates svg item if the relationship is 2D. + This makes it possible to define e.g. an arow icon for relationships that express direction. Args: change (GraphicsItemChange): a flag signalling the type of the change @@ -302,7 +522,8 @@ def itemChange(self, change, value): """ if change == QGraphicsItem.ItemScenePositionHasChanged: self._moved_on_scene = True - return value + self._rotate_svg_item() + return super().itemChange(change, value) def setVisible(self, on): """Sets visibility status for this item and all arc items. @@ -312,10 +533,27 @@ def setVisible(self, on): """ super().setVisible(on) for arc_item in self.arc_items: - arc_item.setVisible(arc_item.obj_item.isVisible() and arc_item.rel_item.isVisible()) + arc_item.setVisible(arc_item.el_item.isVisible() and arc_item.ent_item.isVisible()) def _make_menu(self): - return self._spine_db_editor.ui.graphicsView.make_items_menu() + menu = self._spine_db_editor.ui.graphicsView.make_items_menu() + expand_menu = QMenu("Expand", menu) + expand_menu.triggered.connect(self._expand) + collapse_menu = QMenu("Collapse", menu) + collapse_menu.triggered.connect(self._collapse) + connect_entities_menu = QMenu("Connect entities", menu) + connect_entities_menu.triggered.connect(self._start_connecting_entities) + self._refresh_db_map_entity_class_lists() + self._populate_expand_collapse_menu(expand_menu) + self._populate_expand_collapse_menu(collapse_menu) + self._populate_connect_entities_menu(connect_entities_menu) + first = menu.actions()[0] + first = menu.insertSeparator(first) + first = menu.insertMenu(first, connect_entities_menu) + first = menu.insertMenu(first, collapse_menu) + menu.insertMenu(first, expand_menu) + menu.addAction("Duplicate", self._duplicate) + return menu def contextMenuEvent(self, e): """Shows context menu. @@ -343,217 +581,49 @@ def add_db_map_ids(self, db_map_ids): self._removed_db_map_ids = tuple(x for x in self._removed_db_map_ids if x != db_map_id) self.setToolTip(self._make_tool_tip()) - -class RelationshipItem(EntityItem): - """Represents a relationship in the Entity graph.""" - - def __init__(self, spine_db_editor, x, y, extent, db_map_ids): - """Initializes the item. - - Args: - spine_db_editor (GraphViewForm): 'owner' - x (float): x-coordinate of central point - y (float): y-coordinate of central point - extent (int): preferred extent - db_map_ids (tuple): tuple of (db_map, id) tuples - """ - super().__init__(spine_db_editor, x, y, extent, db_map_ids=db_map_ids) - - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - if not self.db_map_ids: - return {} - return dict( - relationship_class_name=self.entity_class_name, - object_name_list=DB_ITEM_SEPARATOR.join(self.object_name_list), - database=self.first_db_map.codename, - ) - - @property - def entity_type(self): - return "relationship" - - @property - def object_class_id_list(self): - # FIXME: where is this used? - return self.db_mngr.get_item(self.first_db_map, "relationship_class", self.first_entity_class_id).get( - "object_class_id_list" - ) - - @property - def object_name_list(self): - return self.db_mngr.get_item(self.first_db_map, "relationship", self.first_id).get("object_name_list", "") - - def object_id_list(self, db_map): - return self.db_mngr.get_item(db_map, "relationship", self.entity_id(db_map)).get("object_id_list") - - def db_representation(self, db_map): - return dict( - class_id=self.entity_class_id(db_map), - id=self.entity_id(db_map), - object_id_list=self.object_id_list(db_map), - object_name_list=self.object_name_list, - ) - - def _make_tool_tip(self): - if not self.first_id: - return None - return ( - f"""

{self.entity_class_name}
""" - f"""{DB_ITEM_SEPARATOR.join(self.object_name_list)}
""" - f"""@{self.display_database}

""" - ) - - def _init_bg(self): - extent = self._extent - self._bg = QGraphicsEllipseItem(-0.5 * extent, -0.5 * extent, extent, extent, self) - self._bg.setPen(Qt.NoPen) - self._bg_brush = QGuiApplication.palette().button() - - def follow_object_by(self, dx, dy): - factor = 1.0 / len(set(arc.obj_item for arc in self.arc_items)) - self.moveBy(factor * dx, factor * dy) - - def add_arc_item(self, arc_item): - super().add_arc_item(arc_item) - self._rotate_svg_item() - - def itemChange(self, change, value): - """Rotates svg item if the relationship is 2D. - This makes it possible to define e.g. an arow icon for relationships that express direction. - """ - if change == QGraphicsItem.ItemScenePositionHasChanged: - self._rotate_svg_item() - return super().itemChange(change, value) - def _rotate_svg_item(self): - if len(self.arc_items) != 2: + arc_items_as_ent = [x for x in self.arc_items if x.ent_item is self] + if len(arc_items_as_ent) != 2 or self.first_id is None: self._svg_item.setRotation(0) return - arc1, arc2 = self.arc_items # pylint: disable=unbalanced-tuple-unpacking - obj1, obj2 = arc1.obj_item, arc2.obj_item - line = QLineF(obj1.pos(), obj2.pos()) + first_dimension = self.dimension_name_list[0] + element1 = arc_items_as_ent[0].el_item + element2 = arc_items_as_ent[1].el_item + if element1.entity_class_name == first_dimension: + start = element1.pos() + end = element2.pos() + else: + start = element2.pos() + end = element1.pos() + line = QLineF(start, end) self._svg_item.setRotation(-line.angle()) - -class ObjectItem(EntityItem): - """Represents an object in the Entity graph.""" - - def __init__(self, spine_db_editor, x, y, extent, db_map_ids): - """Initializes the item. - - Args: - spine_db_editor (GraphViewForm): 'owner' - x (float): x-coordinate of central point - y (float): y-coordinate of central point - extent (int): preferred extent - db_map_ids (tuple): tuple of (db_map, id) tuples - """ - super().__init__(spine_db_editor, x, y, extent, db_map_ids=db_map_ids) - self._db_map_relationship_class_lists = {} - self.label_item = ObjectLabelItem(self) - self.setZValue(0.5) - self.update_name() - - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - if not self.db_map_ids: - return {} - return dict( - object_class_name=self.entity_class_name, object_name=self.entity_name, database=self.first_db_map.codename - ) - - @property - def entity_type(self): - return "object" - - def db_representation(self, db_map): - return dict(class_id=self.entity_class_id(db_map), id=self.entity_id(db_map), name=self.entity_name) - - def shape(self): - path = super().shape() - path.addPolygon(self.label_item.mapToItem(self, self.label_item.boundingRect())) - return path - - def update_name(self): - """Refreshes the name.""" - db_map_ids_by_name = dict() - for db_map, id_ in self.db_map_ids: - name = self.db_mngr.get_item(db_map, self.entity_type, id_)["name"] - db_map_ids_by_name.setdefault(name, list()).append((db_map, id_)) - if len(db_map_ids_by_name) == 1: - name = next(iter(db_map_ids_by_name)) - self.label_item.setPlainText(name) - return True - current_name = self.label_item.toPlainText() - self._db_map_ids = tuple(db_map_ids_by_name.get(current_name, ())) - return False - - def _make_tool_tip(self): - if not self.first_id: - return None - return f"

{self.entity_name}
@{self.display_database}" - - def block_move_by(self, dx, dy): - super().block_move_by(dx, dy) - rel_items_follow = self._spine_db_editor.qsettings.value( - "appSettings/relationshipItemsFollow", defaultValue="true" - ) - if rel_items_follow == "false": - return - rel_items = {arc_item.rel_item for arc_item in self.arc_items} - for rel_item in rel_items: - if rel_item.isSelected(): - # The item will move with the selection, so no need to follow the objects - continue - rel_item.follow_object_by(dx, dy) - def mouseDoubleClickEvent(self, e): - add_relationships_menu = QMenu(self._spine_db_editor) - title = TitleWidgetAction("Add relationships", self._spine_db_editor) - add_relationships_menu.addAction(title) - add_relationships_menu.triggered.connect(self._start_relationship) - self._refresh_relationship_classes() - self._populate_add_relationships_menu(add_relationships_menu) - add_relationships_menu.popup(e.screenPos()) - - def _make_menu(self): - menu = super()._make_menu() - expand_menu = QMenu("Expand", menu) - expand_menu.triggered.connect(self._expand) - collapse_menu = QMenu("Collapse", menu) - collapse_menu.triggered.connect(self._collapse) - add_relationships_menu = QMenu("Add relationships", menu) - add_relationships_menu.triggered.connect(self._start_relationship) - self._refresh_relationship_classes() - self._populate_expand_collapse_menu(expand_menu) - self._populate_expand_collapse_menu(collapse_menu) - self._populate_add_relationships_menu(add_relationships_menu) - first = menu.actions()[0] - first = menu.insertSeparator(first) - first = menu.insertMenu(first, add_relationships_menu) - first = menu.insertMenu(first, collapse_menu) - menu.insertMenu(first, expand_menu) - menu.addAction("Duplicate", self._duplicate) - return menu + connect_entities_menu = QMenu(self._spine_db_editor) + title = TitleWidgetAction("Connect entities", self._spine_db_editor) + connect_entities_menu.addAction(title) + connect_entities_menu.triggered.connect(self._start_connecting_entities) + self._refresh_db_map_entity_class_lists() + self._populate_connect_entities_menu(connect_entities_menu) + connect_entities_menu.popup(e.screenPos()) def _duplicate(self): - self._spine_db_editor.duplicate_object(self) - - def _refresh_relationship_classes(self): - self._db_map_relationship_class_lists.clear() - db_map_object_ids = {db_map: {id_} for db_map, id_ in self.db_map_ids} - relationship_ids_per_class = {} - for db_map, rels in self.db_mngr.find_cascading_relationships(db_map_object_ids).items(): - for rel in rels: - relationship_ids_per_class.setdefault((db_map, rel["class_id"]), set()).add(rel["id"]) - db_map_object_class_ids = {db_map: {self.entity_class_id(db_map)} for db_map in self.db_maps} - for db_map, rel_clss in self.db_mngr.find_cascading_relationship_classes(db_map_object_class_ids).items(): - for rel_cls in rel_clss: - rel_cls = rel_cls.copy() - rel_cls["object_class_id_list"] = list(rel_cls["object_class_id_list"]) - rel_cls["relationship_ids"] = relationship_ids_per_class.get((db_map, rel_cls["id"]), set()) - self._db_map_relationship_class_lists.setdefault(rel_cls["name"], []).append((db_map, rel_cls)) + self._spine_db_editor.duplicate_entity(self) + + def _refresh_db_map_entity_class_lists(self): + self._db_map_entity_class_lists.clear() + db_map_entity_ids = {db_map: {id_} for db_map, id_ in self.db_map_ids} + entity_ids_per_class = {} + for db_map, ents in self.db_mngr.find_cascading_entities(db_map_entity_ids).items(): + for ent in ents: + entity_ids_per_class.setdefault((db_map, ent["class_id"]), set()).add(ent["id"]) + db_map_entity_class_ids = {db_map: self.entity_class_ids(db_map) for db_map in self.db_maps} + for db_map, ent_clss in self.db_mngr.find_cascading_entity_classes(db_map_entity_class_ids).items(): + for ent_cls in ent_clss: + ent_cls = ent_cls._extended() + ent_cls["dimension_id_list"] = list(ent_cls["dimension_id_list"]) + ent_cls["entity_ids"] = entity_ids_per_class.get((db_map, ent_cls["id"]), set()) + self._db_map_entity_class_lists.setdefault(ent_cls["name"], []).append((db_map, ent_cls)) def _populate_expand_collapse_menu(self, menu): """ @@ -562,102 +632,110 @@ def _populate_expand_collapse_menu(self, menu): Args: menu (QMenu) """ - if not self._db_map_relationship_class_lists: + if not self._db_map_entity_class_lists: menu.setEnabled(False) return menu.setEnabled(True) menu.addAction("All") menu.addSeparator() - for name, db_map_rel_cls_lst in sorted(self._db_map_relationship_class_lists.items()): - db_map, rel_cls = next(iter(db_map_rel_cls_lst)) - icon = self.db_mngr.entity_class_icon(db_map, "relationship_class", rel_cls["id"]) - menu.addAction(icon, name).setEnabled( - any(rel_cls["relationship_ids"] for (db_map, rel_cls) in db_map_rel_cls_lst) - ) + for name, db_map_ent_clss in sorted(self._db_map_entity_class_lists.items()): + db_map, ent_cls = next(iter(db_map_ent_clss)) + icon = self.db_mngr.entity_class_icon(db_map, ent_cls["id"]) + menu.addAction(icon, name).setEnabled(any(rel_cls["entity_ids"] for (db_map, rel_cls) in db_map_ent_clss)) - def _populate_add_relationships_menu(self, menu): + def _populate_connect_entities_menu(self, menu): """ Populates the 'Add relationships' menu. Args: menu (QMenu) """ - object_class_ids_in_graph = {} + entity_class_ids_in_graph = {} for item in self._spine_db_editor.ui.graphicsView.entity_items: - if not isinstance(item, ObjectItem): + if not isinstance(item, EntityItem): continue for db_map in item.db_maps: - object_class_ids_in_graph.setdefault(db_map, set()).add(item.entity_class_id(db_map)) + entity_class_ids_in_graph.setdefault(db_map, set()).update(item.entity_class_ids(db_map)) action_name_icon_enabled = [] - for name, db_map_rel_cls_lst in self._db_map_relationship_class_lists.items(): - for db_map, rel_cls in db_map_rel_cls_lst: - icon = self.db_mngr.entity_class_icon(db_map, "relationship_class", rel_cls["id"]) + for name, db_map_ent_clss in self._db_map_entity_class_lists.items(): + for db_map, ent_cls in db_map_ent_clss: + icon = self.db_mngr.entity_class_icon(db_map, ent_cls["id"]) action_name = name + "@" + db_map.codename - enabled = set(rel_cls["object_class_id_list"]) <= object_class_ids_in_graph.get(db_map, set()) + enabled = set(ent_cls["dimension_id_list"]) <= entity_class_ids_in_graph.get(db_map, set()) action_name_icon_enabled.append((action_name, icon, enabled)) for action_name, icon, enabled in sorted(action_name_icon_enabled): menu.addAction(icon, action_name).setEnabled(enabled) - menu.setEnabled(bool(self._db_map_relationship_class_lists)) - - def _get_db_map_relationship_ids_to_expand_or_collapse(self, action): - db_map_rel_clss = self._db_map_relationship_class_lists.get(action.text()) - if db_map_rel_clss is not None: - return {(db_map, id_) for db_map, rel_cls in db_map_rel_clss for id_ in rel_cls["relationship_ids"]} - return { - (db_map, id_) - for class_list in self._db_map_relationship_class_lists.values() - for db_map, rel_cls in class_list - for id_ in rel_cls["relationship_ids"] - } + menu.setEnabled(bool(self._db_map_entity_class_lists)) + + def _get_db_map_entity_ids_to_expand_or_collapse(self, action): + if action.text() == "All": + return { + (db_map, id_) + for db_map_ent_clss in self._db_map_entity_class_lists.values() + for db_map, ent_cls in db_map_ent_clss + for id_ in ent_cls["entity_ids"] + } + db_map_ent_clss = self._db_map_entity_class_lists.get(action.text()) + if db_map_ent_clss is not None: + return {(db_map, id_) for db_map, ent_cls in db_map_ent_clss for id_ in ent_cls["entity_ids"]} + return () @Slot(QAction) def _expand(self, action): - db_map_relationship_ids = self._get_db_map_relationship_ids_to_expand_or_collapse(action) - self._spine_db_editor.added_db_map_relationship_ids.update(db_map_relationship_ids) - self._spine_db_editor.build_graph(persistent=True) + db_map_entity_ids = self._get_db_map_entity_ids_to_expand_or_collapse(action) + self._spine_db_editor.expand_graph(db_map_entity_ids) @Slot(QAction) def _collapse(self, action): - db_map_relationship_ids = self._get_db_map_relationship_ids_to_expand_or_collapse(action) - self._spine_db_editor.added_db_map_relationship_ids.difference_update(db_map_relationship_ids) - self._spine_db_editor.build_graph(persistent=True) + db_map_entity_ids = self._get_db_map_entity_ids_to_expand_or_collapse(action) + self._spine_db_editor.collapse_graph(db_map_entity_ids) @Slot(QAction) - def _start_relationship(self, action): + def _start_connecting_entities(self, action): class_name, db_name = action.text().split("@") - db_map_rel_cls_lst = self._db_map_relationship_class_lists[class_name] - db_map, rel_cls = next( - iter((db_map, rel_cls) for db_map, rel_cls in db_map_rel_cls_lst if db_map.codename == db_name) + db_map_ent_cls_lst = self._db_map_entity_class_lists[class_name] + db_map, ent_cls = next( + iter((db_map, ent_cls) for db_map, ent_cls in db_map_ent_cls_lst if db_map.codename == db_name) ) - self._spine_db_editor.start_relationship(db_map, rel_cls, self) + self._spine_db_editor.start_connecting_entities(db_map, ent_cls, self) class ArcItem(QGraphicsPathItem): - """Connects a RelationshipItem to an ObjectItem.""" - - def __init__(self, rel_item, obj_item, width): - """Initializes item. + """Connects two EntityItems.""" + def __init__(self, ent_item, el_item, width): + """ Args: - rel_item (spinetoolbox.widgets.graph_view_graphics_items.RelationshipItem): relationship item - obj_item (spinetoolbox.widgets.graph_view_graphics_items.ObjectItem): object item + ent_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): entity item + el_item (spinetoolbox.widgets.graph_view_graphics_items.EntityItem): element item width (float): Preferred line width """ super().__init__() - self.rel_item = rel_item - self.obj_item = obj_item - self._width = float(width) + self.ent_item = ent_item + self.el_item = el_item + self._original_width = float(width) self._pen = self._make_pen() self.setPen(self._pen) self.setZValue(-2) - rel_item.add_arc_item(self) - obj_item.add_arc_item(self) + self._scaling_factor = 1 + self._gradient = QGraphicsPathItem(self) + self._gradient.setPen(Qt.NoPen) + self._gradient_position = 0.5 + self._gradient_width = 1 + self._gradient_sign = 1 + ent_item.add_arc_item(self) + el_item.add_arc_item(self) self.setCursor(Qt.ArrowCursor) self.update_line() + def clone(self, entity_items): + ent_item = entity_items[self.ent_item.db_map_ids] + el_item = entity_items[self.el_item.db_map_ids] + return type(self)(ent_item, el_item, self._original_width) + def _make_pen(self): pen = QPen() - pen.setWidth(self._width) + pen.setWidthF(self._original_width) color = QGuiApplication.palette().color(QPalette.Normal, QPalette.WindowText) color.setAlphaF(0.8) pen.setColor(color) @@ -669,29 +747,28 @@ def moveBy(self, dx, dy): """Does nothing. This item is not moved the regular way, but follows the EntityItems it connects.""" def update_line(self): - overlapping_arcs = [arc for arc in self.rel_item.arc_items if arc.obj_item == self.obj_item] - count = len(overlapping_arcs) - path = QPainterPath(self.rel_item.pos()) - if count == 1: - path.lineTo(self.obj_item.pos()) - else: - rank = overlapping_arcs.index(self) - line = QLineF(self.rel_item.pos(), self.obj_item.pos()) - line.setP1(line.center()) - line = line.normalVector() - line.setLength(self._width * count) - line.setP1(2 * line.p1() - line.p2()) - t = rank / (count - 1) - ctrl_point = line.pointAt(t) - path.quadTo(ctrl_point, self.obj_item.pos()) + path = QPainterPath(self.ent_item.pos()) + path.lineTo(self.el_item.pos()) self.setPath(path) + self._do_move_gradient() + + def update_color(self, color): + self._pen.setColor(color) + self.setPen(self._pen) + color = QColor(color) + color.setAlphaF(0.5) + self._gradient.setBrush(color) + + def apply_value(self, factor, sign): + self._update_width() + self._move_gradient(factor, sign) def mousePressEvent(self, event): """Accepts the event so it's not propagated.""" event.accept() def other_item(self, item): - return {self.rel_item: self.obj_item, self.obj_item: self.rel_item}.get(item) + return {self.ent_item: self.el_item, self.el_item: self.ent_item}.get(item) def apply_zoom(self, factor): """Applies zoom. @@ -699,14 +776,42 @@ def apply_zoom(self, factor): Args: factor (float): The zoom factor. """ - if factor < 1: - factor = 1 - scaled_width = self._width / factor - self._pen.setWidthF(scaled_width) - self.setPen(self._pen) + self._scaling_factor = max(factor, 1) + self._update_width() + def _update_width(self): + width = self._original_width / self._scaling_factor + self._pen.setWidthF(width) + self.setPen(self._pen) -class CrossHairsItem(RelationshipItem): + def _move_gradient(self, factor, sign): + self._gradient_sign = sign + self._gradient_width = max(1, factor) + self._gradient_position += 0.1 * self._gradient_sign / self._scaling_factor + if self._gradient_position > 1: + self._gradient_position -= 1 + elif self._gradient_position < 0: + self._gradient_position += 1 + self._do_move_gradient() + + def _do_move_gradient(self): + width = self._original_width * self._gradient_width / self._scaling_factor + init_pos, final_pos = self.ent_item.pos(), self.el_item.pos() + line = QLineF(init_pos, final_pos) + line.translate(self._gradient_position * line.dx(), self._gradient_position * line.dy()) + line.setLength(width) + line.translate(-line.dx() / 2, -line.dy() / 2) + if self._gradient_sign < 0: + line.setPoints(line.p2(), line.p1()) + normal = line.normalVector() + normal.translate(-normal.dx() / 2, -normal.dy() / 2) + path = QPainterPath(line.p2()) + path.lineTo(normal.p1()) + path.lineTo(normal.p2()) + self._gradient.setPath(path) + + +class CrossHairsItem(EntityItem): """Creates new relationships directly in the graph.""" def __init__(self, *args, **kwargs): @@ -720,15 +825,22 @@ def entity_class_name(self): return None @property - def entity_name(self): + def name(self): return None + @property + def has_dimensions(self): + return False + def _make_tool_tip(self): return "

Click on an object to add it to the relationship.

" + def _has_name(self): + return False + def refresh_icon(self): - renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer("\uf05b", 0) - self._set_renderer(renderer) + self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer("\uf05b", 0) + self._install_renderer() def set_plus_icon(self): self.set_icon("\uf067", Qt.blue) @@ -746,52 +858,54 @@ def set_icon(self, unicode, color=0): """Refreshes the icon.""" if (unicode, color) == self._current_icon: return - renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer(unicode, color) - self._set_renderer(renderer) + self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).icon_renderer(unicode, color) + self._install_renderer() self._current_icon = (unicode, color) - def mouseMoveEvent(self, event): - move_by = event.scenePos() - self.scenePos() - self.block_move_by(move_by.x(), move_by.y()) + def _snap(self, x, y): + return (x, y) - def block_move_by(self, dx, dy): - self.moveBy(dx, dy) - rel_items = {arc_item.rel_item for arc_item in self.arc_items} - for rel_item in rel_items: - rel_item.follow_object_by(dx, dy) + def mouseMoveEvent(self, event): + delta = event.scenePos() - self.scenePos() + self.move_by(delta.x(), delta.y()) def contextMenuEvent(self, e): e.accept() -class CrossHairsRelationshipItem(RelationshipItem): +class CrossHairsEntityItem(EntityItem): """Represents the relationship that's being created using the CrossHairsItem.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False) + @property + def has_dimensions(self): + return True + def _make_tool_tip(self): return None + def _has_name(self): + return False + def refresh_icon(self): """Refreshes the icon.""" - obj_items = [arc_item.obj_item for arc_item in self.arc_items] - object_class_name_list = tuple( - obj_item.entity_class_name for obj_item in obj_items if not isinstance(obj_item, CrossHairsItem) + el_items = [arc_item.el_item for arc_item in self.arc_items] + dimension_name_list = tuple( + el_item.entity_class_name for el_item in el_items if not isinstance(el_item, CrossHairsItem) ) - renderer = self.db_mngr.get_icon_mngr(self.first_db_map).relationship_class_renderer( - None, object_class_name_list - ) - self._set_renderer(renderer) + self._renderer = self.db_mngr.get_icon_mngr(self.first_db_map).multi_class_renderer(dimension_name_list) + self._install_renderer() def contextMenuEvent(self, e): e.accept() class CrossHairsArcItem(ArcItem): - """Connects a CrossHairsRelationshipItem with the CrossHairsItem, - and with all the ObjectItem's in the relationship so far. + """Connects a CrossHairsEntityItem with the CrossHairsItem, + and with all the EntityItem's in the relationship so far. """ def _make_pen(self): @@ -803,8 +917,8 @@ def _make_pen(self): return pen -class ObjectLabelItem(QGraphicsTextItem): - """Provides a label for ObjectItem's.""" +class EntityLabelItem(QGraphicsTextItem): + """Provides a label for EntityItem.""" entity_name_edited = Signal(str) @@ -828,6 +942,11 @@ def __init__(self, entity_item): self.setFlag(QGraphicsItem.ItemIsSelectable, enabled=False) self.setAcceptHoverEvents(False) + def boundingRect(self): + if not self.isVisible(): + return QRectF() + return super().boundingRect() + def setPlainText(self, text): """Set texts and resets position. @@ -844,3 +963,154 @@ def reset_position(self): y = rectf.height() + 4 self.setPos(x, y) self.bg.setRect(self.boundingRect()) + + +class BgItem(QGraphicsRectItem): + class Anchor(Enum): + TL = auto() + TR = auto() + BL = auto() + BR = auto() + + _getter_setter = { + Anchor.TL: ("topLeft", "setTopLeft"), + Anchor.TR: ("topRight", "setTopRight"), + Anchor.BL: ("bottomLeft", "setBottomLeft"), + Anchor.BR: ("bottomRight", "setBottomRight"), + } + + _cursors = { + Anchor.TL: Qt.SizeFDiagCursor, + Anchor.TR: Qt.SizeBDiagCursor, + Anchor.BL: Qt.SizeBDiagCursor, + Anchor.BR: Qt.SizeFDiagCursor, + } + + def __init__(self, svg, parent=None): + super().__init__(parent) + self._renderer = QSvgRenderer() + self._svg_item = _ResizableQGraphicsSvgItem(self) + self.svg = svg + _loading_ok = self._renderer.load(QByteArray(self.svg)) + self._svg_item.setCacheMode(QGraphicsItem.CacheMode.NoCache) # Needed for the exported pdf to be vector + self._svg_item.setSharedRenderer(self._renderer) + self._scaling_factor = 1 + size = self._renderer.defaultSize() + self.setRect(0, 0, size.width(), size.height()) + self.setZValue(-1000) + self.setPen(Qt.NoPen) + self.setAcceptHoverEvents(True) + self.setFlag(QGraphicsItem.ItemIsMovable, True) + self._resizers = {anchor: _Resizer(parent=self) for anchor in self.Anchor} + for anchor, resizer in self._resizers.items(): + resizer.resized.connect(lambda delta, strong, anchor=anchor: self._resize(anchor, delta, strong)) + resizer.setCursor(self._cursors[anchor]) + resizer.hide() + + def clone(self): + other = type(self)(self.svg) + other.fit_rect(self.scene_rect()) + return other + + def hoverEnterEvent(self, ev): + super().hoverEnterEvent(ev) + for resizer in self._resizers.values(): + resizer.show() + + def hoverLeaveEvent(self, ev): + super().hoverLeaveEvent(ev) + for resizer in self._resizers.values(): + resizer.hide() + + def apply_zoom(self, factor): + self._scaling_factor = factor + self._place_resizers() + + def _place_resizers(self): + for anchor, resizer in self._resizers.items(): + getter, _ = self._getter_setter[anchor] + resizer.setPos( + getattr(self.rect(), getter)() - getattr(resizer.rect(), getter)() / self.scale() / self._scaling_factor + ) + + def _resize(self, anchor, delta, strong): + delta /= self.scale() * self._scaling_factor + rect = self.rect() + getter, setter = self._getter_setter[anchor] + get_point = getattr(rect, getter) + set_point = getattr(rect, setter) + set_point(get_point() + delta) + self._do_resize(rect, strong) + + def _do_resize(self, rect, strong): + if strong: + self._svg_item.resize(rect.width(), rect.height()) + self._svg_item.setPos(rect.topLeft()) + self.setPen(Qt.NoPen) + else: + self.setPen(QPen(Qt.DashLine)) + self.setRect(rect) + self.prepareGeometryChange() + self.update() + self._place_resizers() + + def fit_rect(self, rect): + if not isinstance(rect, QRectF): + rect = QRectF(*rect) + self._do_resize(rect, True) + + def scene_rect(self): + return self.mapToScene(self.rect()).boundingRect() + + +class _ResizableQGraphicsSvgItem(QGraphicsSvgItem): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._width = 0 + self._height = 0 + self.setFlag(QGraphicsItem.ItemStacksBehindParent, True) + + def resize(self, width, height): + self._width = width + self._height = height + self.prepareGeometryChange() + self.update() + + def setSharedRenderer(self, renderer): + super().setSharedRenderer(renderer) + self._width = renderer.defaultSize().width() + self._height = renderer.defaultSize().height() + + def boundingRect(self): + return QRectF(0, 0, self._width, self._height) + + def paint(self, painter, options, widget): + self.renderer().render(painter, self.boundingRect()) + + +class _Resizer(QGraphicsRectItem): + class SignalsProvider(QObject): + resized = Signal(QPointF, bool) + + def __init__(self, rect=QRectF(0, 0, 20, 20), parent=None): + super().__init__(rect, parent) + self._original_rect = self.rect() + self._press_pos = None + self.setFlag(QGraphicsItem.ItemIsMovable, True) + self.setFlag(QGraphicsItem.ItemIgnoresTransformations, True) + self._signal_provider = self.SignalsProvider() + self.resized = self._signal_provider.resized + + def mousePressEvent(self, ev): + super().mousePressEvent(ev) + self._press_pos = ev.pos() + + def mouseMoveEvent(self, ev): + super().mouseMoveEvent(ev) + delta = ev.pos() - self._press_pos + self._signal_provider.resized.emit(delta, False) + + def mouseReleaseEvent(self, ev): + super().mouseReleaseEvent(ev) + delta = ev.pos() - self._press_pos + self._signal_provider.resized.emit(delta, True) diff --git a/spinetoolbox/widgets/upgrade_notification.py b/spinetoolbox/spine_db_editor/helpers.py similarity index 56% rename from spinetoolbox/widgets/upgrade_notification.py rename to spinetoolbox/spine_db_editor/helpers.py index e6fb62a66..19945f6ec 100644 --- a/spinetoolbox/widgets/upgrade_notification.py +++ b/spinetoolbox/spine_db_editor/helpers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,22 +9,38 @@ # 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 . ###################################################################################################################### -"""Contains widget and utils to notify users of the 0.8 update.""" -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QDialog - - -class UpgradeNotificationDialog(QDialog): - def __init__(self, parent): - """ - Args: - parent (QWidget): parent widget - """ - super().__init__(parent) - from ..ui.upgrade_notification import Ui_Form - - self._ui = Ui_Form() - self._ui.setupUi(self) - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) - self.setWindowTitle("Toolbox 0.8 upgrade information") - self._ui.button_box.rejected.connect(self.close) + +"""Helpers and utilities for Spine Database editor.""" + + +def string_to_display_icon(x): + """Converts a 'foreign' string (from e.g. Excel) to entity class display icon. + + Args: + x (str): string to convert + + Returns: + int: display icon or None if conversion failed + """ + try: + return int(x) + except ValueError: + return None + + +TRUE_STRING = "true" +FALSE_STRING = "false" +GENERIC_TRUE = TRUE_STRING.casefold() +GENERIC_FALSE = FALSE_STRING.casefold() + + +def string_to_bool(x): + """Converts a 'foreign' string (from e.g. Excel) to boolean. + + Args: + x (str): string to convert + + Returns: + bool: boolean value + """ + return x.casefold() == GENERIC_TRUE diff --git a/spinetoolbox/spine_db_editor/main.py b/spinetoolbox/spine_db_editor/main.py index 64c74eaf0..e41a92efe 100644 --- a/spinetoolbox/spine_db_editor/main.py +++ b/spinetoolbox/spine_db_editor/main.py @@ -23,7 +23,7 @@ def main(): status = QFontDatabase.addApplicationFont(":/fonts/fontawesome5-solid-webfont.ttf") if status < 0: logging.warning("Could not load fonts from resources file. Some icons may not render properly.") - locale.setlocale(locale.LC_NUMERIC, 'C') + locale.setlocale(locale.LC_NUMERIC, "C") settings = QSettings("SpineProject", "Spine Toolbox") db_mngr = SpineDBManager(settings, None) editor = MultiSpineDBEditor(db_mngr) @@ -49,5 +49,5 @@ def _make_argument_parser(): return parser -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/__init__.py b/spinetoolbox/spine_db_editor/mvcmodels/__init__.py index 8b96d2413..336d85acc 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/__init__.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py b/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py index 82e5e11c1..07bfd7de2 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/alternative_item.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -28,10 +29,10 @@ def fetch_item_type(self): return "alternative" def empty_child(self): - return AlternativeItem() + return AlternativeItem(self._model) def _make_child(self, id_): - return AlternativeItem(id_) + return AlternativeItem(self._model, id_) class AlternativeItem(GrayIfLastMixin, EditableMixin, LeafItem): @@ -45,10 +46,10 @@ def item_type(self): def icon_code(self): return _ALTERNATIVE_ICON - @property - def tool_tip(self): - if self.id: - return "

Drag this item it onto a scenario item in Scenario tree to add it to that scenario.

" + def tool_tip(self, column): + if column == 0 and self.id: + return "

Drag this item on a scenario item in Scenario tree to add it to that scenario.

" + return super().tool_tip(column) def add_item_to_db(self, db_item): self.db_mngr.add_alternatives({self.db_map: [db_item]}) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py b/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py index 7e5f2495e..36297c182 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/alternative_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,12 +9,11 @@ # 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 . ###################################################################################################################### + """Contains alternative tree model.""" -import csv import pickle -from io import StringIO - -from PySide6.QtCore import QMimeData +from collections import defaultdict +from PySide6.QtCore import QMimeData, QByteArray from .tree_model_base import TreeModelBase from .alternative_item import DBItem from .utils import two_column_as_csv @@ -23,13 +23,8 @@ class AlternativeModel(TreeModelBase): """A model to display alternatives in a tree view.""" - @staticmethod - def _make_db_item(db_map): - return DBItem(db_map) - - @staticmethod - def _top_children(): - return [] + def _make_db_item(self, db_map): + return DBItem(self, db_map) def mimeData(self, indexes): """Stores selected indexes into MIME data. @@ -45,17 +40,17 @@ def mimeData(self, indexes): Returns: QMimeData: MIME data """ - d = {} + d = defaultdict(list) # We have two columns and consequently usually twice the same item per row. # Make items unique without losing order using a dictionary trick. items = list(dict.fromkeys(self.item_from_index(ind) for ind in indexes)) for item in items: db_item = item.parent_item db_key = self.db_mngr.db_map_key(db_item.db_map) - d.setdefault(db_key, []).append(item.id) + d[db_key].append(item.name) mime = QMimeData() mime.setText(two_column_as_csv(indexes)) - mime.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(d)) + mime.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(d))) return mime def paste_alternative_mime_data(self, mime_data, database_item): @@ -65,15 +60,13 @@ def paste_alternative_mime_data(self, mime_data, database_item): mime_data (QMimeData): mime data database_item (alternative_item.DBItem): target database item """ - alternative_data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA)) + alternative_data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()) names_to_descriptions = {} - for db_key, alternative_ids in alternative_data.items(): + for db_key in alternative_data: db_map = self.db_mngr.db_map_from_key(db_key) - items = self.db_mngr.get_items(db_map, "alternative", only_visible=False) - names_to_descriptions.update({i.name: i.description for i in items}) - existing_names = { - item.name for item in self.db_mngr.get_items(database_item.db_map, "alternative", only_visible=False) - } + items = self.db_mngr.get_items(db_map, "alternative") + names_to_descriptions.update({i["name"]: i["description"] for i in items}) + existing_names = {item["name"] for item in self.db_mngr.get_items(database_item.db_map, "alternative")} alternative_db_items = [] for name, description in names_to_descriptions.items(): if name in existing_names: diff --git a/spinetoolbox/spine_db_editor/mvcmodels/colors.py b/spinetoolbox/spine_db_editor/mvcmodels/colors.py index b22633949..6fae16acc 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/colors.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/colors.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,12 +9,10 @@ # 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 . ###################################################################################################################### -""" -Color constants for models. -""" + +"""Color constants for models.""" from PySide6.QtGui import QColor PIVOT_TABLE_HEADER_COLOR = QColor("#efefef") - FIXED_FIELD_COLOR = QColor("lightGray") SELECTED_COLOR = QColor.fromString("paleturquoise") diff --git a/spinetoolbox/spine_db_editor/mvcmodels/compound_parameter_models.py b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py similarity index 65% rename from spinetoolbox/spine_db_editor/mvcmodels/compound_parameter_models.py rename to spinetoolbox/spine_db_editor/mvcmodels/compound_models.py index 516a1ade8..0fbb0e27f 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/compound_parameter_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/compound_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,42 +10,27 @@ # this program. If not, see . ###################################################################################################################### -""" -Compound models for object parameter definitions and values. -These models concatenate several 'single' models and one 'empty' model. -""" +""" Compound models. These models concatenate several 'single' models and one 'empty' model. """ from PySide6.QtCore import Qt, Slot, QTimer, QModelIndex from PySide6.QtGui import QFont from spinedb_api.parameter_value import join_value_and_type -from ...helpers import rows_to_row_count_tuples, parameter_identifier +from ...helpers import parameter_identifier, rows_to_row_count_tuples from ...fetch_parent import FlexibleFetchParent -from ..widgets.custom_menus import ParameterViewFilterMenu from ...mvcmodels.compound_table_model import CompoundWithEmptyTableModel -from .empty_parameter_models import ( - EmptyObjectParameterDefinitionModel, - EmptyObjectParameterValueModel, - EmptyRelationshipParameterDefinitionModel, - EmptyRelationshipParameterValueModel, -) -from .single_parameter_models import ( - SingleObjectParameterDefinitionModel, - SingleObjectParameterValueModel, - SingleRelationshipParameterDefinitionModel, - SingleRelationshipParameterValueModel, -) - - -class CompoundParameterModel(CompoundWithEmptyTableModel): - """A model that concatenates several single parameter models - and one empty parameter model. - """ +from ..widgets.custom_menus import AutoFilterMenu +from .empty_models import EmptyParameterDefinitionModel, EmptyParameterValueModel, EmptyEntityAlternativeModel +from .single_models import SingleParameterDefinitionModel, SingleParameterValueModel, SingleEntityAlternativeModel + + +class CompoundModelBase(CompoundWithEmptyTableModel): + """A base model for all models that show data in stacked format.""" def __init__(self, parent, db_mngr, *db_maps): """ Args: parent (SpineDBEditor): the parent object db_mngr (SpineDBManager): the database manager - *db_maps (DiffDatabaseMapping): the database maps included in the model + *db_maps (DatabaseMapping): the database maps included in the model """ super().__init__(parent=parent, header=self._make_header()) self._parent = parent @@ -59,7 +45,6 @@ def __init__(self, parent, db_mngr, *db_maps): self._filter_timer.timeout.connect(self.refresh) self._fetch_parent = FlexibleFetchParent( self.item_type, - accepts_item=self.accepts_item, shows_item=self.shows_item, handle_items_added=self.handle_items_added, handle_items_removed=self.handle_items_removed, @@ -67,37 +52,16 @@ def __init__(self, parent, db_mngr, *db_maps): owner=self, ) - def canFetchMore(self, _parent): - result = False - for db_map in self.db_maps: - result |= self.db_mngr.can_fetch_more(db_map, self._fetch_parent) - return result - - def fetchMore(self, _parent): - for db_map in self.db_maps: - self.db_mngr.fetch_more(db_map, self._fetch_parent) - - def accepts_item(self, item, db_map): - return item.get(self.entity_class_id_key) is not None - - def shows_item(self, item, db_map): - return any(m.db_map == db_map and m.filter_accepts_item(item) for m in self.accepted_single_models()) - def _make_header(self): raise NotImplementedError() @property - def entity_class_type(self): - """Returns the entity_class type, either 'object_class' or 'relationship_class'. - - Returns: - str - """ - raise NotImplementedError() + def field_map(self): + return {} @property def item_type(self): - """Returns the parameter item type, either 'parameter_definition' or 'parameter_value'. + """Returns the DB item type, e.g., 'parameter_value'. Returns: str @@ -112,16 +76,7 @@ def _single_model_type(self): Returns: SingleParameterModel """ - return { - "object_class": { - "parameter_definition": SingleObjectParameterDefinitionModel, - "parameter_value": SingleObjectParameterValueModel, - }, - "relationship_class": { - "parameter_definition": SingleRelationshipParameterDefinitionModel, - "parameter_value": SingleRelationshipParameterValueModel, - }, - }[self.entity_class_type][self.item_type] + raise NotImplementedError() @property def _empty_model_type(self): @@ -131,32 +86,27 @@ def _empty_model_type(self): Returns: EmptyParameterModel """ - return { - "object_class": { - "parameter_definition": EmptyObjectParameterDefinitionModel, - "parameter_value": EmptyObjectParameterValueModel, - }, - "relationship_class": { - "parameter_definition": EmptyRelationshipParameterDefinitionModel, - "parameter_value": EmptyRelationshipParameterValueModel, - }, - }[self.entity_class_type][self.item_type] + raise NotImplementedError() - @property - def entity_class_id_key(self): - """ - Returns the key corresponding to the entity_class id (either "object_class_id" or "relationship_class_id") + def canFetchMore(self, _parent): + result = False + for db_map in self.db_maps: + result |= self.db_mngr.can_fetch_more(db_map, self._fetch_parent) + return result - Returns: - str - """ - return {"object_class": "object_class_id", "relationship_class": "relationship_class_id"}[ - self.entity_class_type - ] + def fetchMore(self, _parent): + for db_map in self.db_maps: + self.db_mngr.fetch_more(db_map, self._fetch_parent) - @property - def parameter_definition_id_key(self): - return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type] + def shows_item(self, item, db_map): + return any(m.db_map == db_map and m.filter_accepts_item(item) for m in self.accepted_single_models()) + + def reset_db_maps(self, db_maps): + if set(db_maps) == set(self.db_maps): + return + self.db_maps = db_maps + self._fetch_parent.set_obsolete(False) + self._fetch_parent.reset() def init_model(self): """Initializes the model.""" @@ -166,7 +116,7 @@ def init_model(self): self.empty_model.fetchMore(QModelIndex()) while self._auto_filter_menus: _, menu = self._auto_filter_menus.popitem() - menu.wipe_out() + menu.deleteLater() def get_auto_filter_menu(self, logical_index): """Returns auto filter menu for given logical index from header view. @@ -175,32 +125,29 @@ def get_auto_filter_menu(self, logical_index): logical_index (int) Returns: - ParameterViewFilterMenu + AutoFilterMenu """ return self._make_auto_filter_menu(self.header[logical_index]) def _make_auto_filter_menu(self, field): + field = self.field_map.get(field, field) if field not in self._auto_filter_menus: - self._auto_filter_menus[field] = menu = ParameterViewFilterMenu( - self._parent, - self.db_mngr, - self.db_maps, - self.item_type, - self.entity_class_id_key, - field, - show_empty=False, + self._auto_filter_menus[field] = menu = AutoFilterMenu( + self._parent, self.db_mngr, self.db_maps, self.item_type, field, show_empty=False ) menu.filterChanged.connect(self.set_auto_filter) return self._auto_filter_menus[field] def headerData(self, section, orientation=Qt.Orientation.Horizontal, role=Qt.ItemDataRole.DisplayRole): """Returns an italic font in case the given column has an autofilter installed.""" + field = self.header[section] + real_field = self.field_map.get(field, field) italic_font = QFont() italic_font.setItalic(True) if ( role == Qt.ItemDataRole.FontRole and orientation == Qt.Orientation.Horizontal - and self._auto_filter.get(self.header[section], {}) != {} + and self._auto_filter.get(real_field, {}) != {} ): return italic_font return super().headerData(section, orientation, role) @@ -211,13 +158,13 @@ def _create_empty_model(self): Returns: EmptyParameterModel """ - return self._empty_model_type(self, self.header, self.db_mngr) + return self._empty_model_type(self) def filter_accepts_model(self, model): """Returns a boolean indicating whether the given model passes the filter for compound model. Args: - model (SingleParameterModel, EmptyParameterModel) + model (SingleModelBase or EmptyModelBase) Returns: bool @@ -233,7 +180,8 @@ def filter_accepts_model(self, model): def _class_filter_accepts_model(self, model): if not self._filter_class_ids: return True - return model.entity_class_id in self._filter_class_ids.get(model.db_map, set()) + class_ids = self._filter_class_ids.get(model.db_map, set()) + return model.entity_class_id in class_ids or bool(set(model.dimension_id_list) & class_ids) def _auto_filter_accepts_model(self, model): if None in self._auto_filter.values(): @@ -331,14 +279,15 @@ def _models_with_db_map(self, db_map): """Returns a collection of single models with given db_map. Args: - db_map (DiffDatabaseMapping) + db_map (DatabaseMapping) Returns: list """ return [m for m in self.single_models if m.db_map == db_map] - def _items_per_class(self, items): + @staticmethod + def _items_per_class(items): """Returns a dict mapping entity_class ids to a set of items. Args: @@ -347,12 +296,12 @@ def _items_per_class(self, items): Returns: dict """ - d = dict() + d = {} for item in items: - entity_class_id = item.get(self.entity_class_id_key) + entity_class_id = item.get("entity_class_id") if not entity_class_id: continue - d.setdefault(entity_class_id, list()).append(item) + d.setdefault(entity_class_id, []).append(item) return d def handle_items_added(self, db_map_data): @@ -361,26 +310,27 @@ def handle_items_added(self, db_map_data): Also notifies the empty model so it can remove rows that are already in. Args: - db_map_data (dict): list of added dict-items keyed by DiffDatabaseMapping + db_map_data (dict): list of added dict-items keyed by DatabaseMapping """ for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue db_map_single_models = [m for m in self.single_models if m.db_map is db_map] existing_ids = set().union(*(m.item_ids() for m in db_map_single_models)) items_per_class = self._items_per_class(items) for entity_class_id, class_items in items_per_class.items(): - ids_committed = list() - ids_uncommitted = list() + ids_committed = [] + ids_uncommitted = [] for item in class_items: - is_committed = db_map.commit_id() is None or item["commit_id"] != db_map.commit_id() item_id = item["id"] if item_id in existing_ids: continue - if is_committed: + if item.is_committed(): ids_committed.append(item_id) else: ids_uncommitted.append(item_id) - self._add_parameter_data(db_map, entity_class_id, ids_committed, committed=True) - self._add_parameter_data(db_map, entity_class_id, ids_uncommitted, committed=False) + self._add_items(db_map, entity_class_id, ids_committed, committed=True) + self._add_items(db_map, entity_class_id, ids_uncommitted, committed=False) self.empty_model.handle_items_added(db_map_data) def _get_insert_position(self, model): @@ -389,17 +339,17 @@ def _get_insert_position(self, model): return len(self.single_models) def _create_single_model(self, db_map, entity_class_id, committed): - model = self._single_model_type(self.header, self.db_mngr, db_map, entity_class_id, committed) + model = self._single_model_type(self, db_map, entity_class_id, committed) self._connect_single_model(model) for field in self._auto_filter: self._set_single_auto_filter(model, field) return model - def _add_parameter_data(self, db_map, entity_class_id, ids, committed): + def _add_items(self, db_map, entity_class_id, ids, committed): """Creates new single model and resets it with the given parameter ids. Args: - db_map (DiffDatabaseMapping): database map + db_map (DatabaseMapping): database map entity_class_id (int): parameter's entity class id ids (list of int): parameter ids committed (bool): True if the ids have been committed, False otherwise @@ -421,46 +371,51 @@ def handle_items_updated(self, db_map_data): Emits dataChanged so the parameter_name column is refreshed. Args: - db_map_data (dict): list of updated dict-items keyed by DiffDatabaseMapping + db_map_data (dict): list of updated dict-items keyed by DatabaseMapping """ - self._emit_data_changed_for_column("parameter_name") - # NOTE: parameter_definition names aren't refreshed unless we emit dataChanged, - # whereas entity and class names are. Why? + if all(db_map not in self.db_maps for db_map in db_map_data): + return + self.dataChanged.emit( + self.index(0, 0), self.index(self.rowCount() - 1, self.columnCount() - 1), [Qt.ItemDataRole.DisplayRole] + ) def handle_items_removed(self, db_map_data): """Runs when either parameter definitions or values are removed from the dbs. Removes the affected rows from the corresponding single models. Args: - db_map_data (dict): list of removed dict-items keyed by DiffDatabaseMapping + db_map_data (dict): list of removed dict-items keyed by DatabaseMapping """ self.layoutAboutToBeChanged.emit() for db_map, items in db_map_data.items(): + if db_map not in self.db_maps: + continue items_per_class = self._items_per_class(items) - for model in self._models_with_db_map(db_map): - removed_ids = [x["id"] for x in items_per_class.get(model.entity_class_id, {})] + emptied_single_model_indexes = [] + for model_index, model in enumerate(self.single_models): + if model.db_map != db_map: + continue + removed_ids = {x["id"] for x in items_per_class.get(model.entity_class_id, {})} if not removed_ids: continue - removed_rows = [row for row in range(model.rowCount()) if model._main_data[row] in removed_ids] + removed_rows = [] + for row in range(model.rowCount()): + id_ = model._main_data[row] + if id_ in removed_ids: + removed_rows.append(row) + removed_ids.remove(id_) + if not removed_ids: + break for row, count in sorted(rows_to_row_count_tuples(removed_rows), reverse=True): del model._main_data[row : row + count] + if model.rowCount() == 0: + emptied_single_model_indexes.append(model_index) + for model_index in reversed(emptied_single_model_indexes): + model = self.sub_models.pop(model_index) + model.deleteLater() self._do_refresh() self.layoutChanged.emit() - def _emit_data_changed_for_column(self, field): - """Lazily emits data changed for an entire column. - - Args: - field (str): the column header - """ - try: - column = self.header.index(field) - except ValueError: - return - self.dataChanged.emit( - self.index(0, column), self.index(self.rowCount() - 1, column), [Qt.ItemDataRole.DisplayRole] - ) - def db_item(self, index): sub_index = self.map_to_sub(index) return sub_index.model().db_item(sub_index) @@ -472,58 +427,9 @@ def db_map_id(self, index): return None, None return sub_model.db_map, sub_model.item_id(sub_index.row()) - def index_name(self, index): - """Generates a name for data at given index. - - Args: - index (QModelIndex): index to model - - Returns: - str: label identifying the data - """ - item = self.db_item(index) - if item is None: - return "" - database = self.index(index.row(), self.columnCount() - 1).data() - name_key = { - "parameter_definition": { - "object_class": "object_class_name", - "relationship_class": "relationship_class_name", - }, - "parameter_value": {"object_class": "object_name", "relationship_class": "object_name_list"}, - }[self.item_type][self.entity_class_type] - name = item[name_key] - names = [name] if not isinstance(name, tuple) else list(name) - alternative_name = {"parameter_definition": lambda x: None, "parameter_value": lambda x: x["alternative_name"]}[ - self.item_type - ](item) - return parameter_identifier(database, item["parameter_name"], names, alternative_name) - - def get_set_data_delayed(self, index): - """Returns a function that ParameterValueEditor can call to set data for the given index at any later time, - even if the model changes. - - Args: - index (QModelIndex) - - Returns: - function - """ - sub_model = self.sub_model_at_row(index.row()) - if sub_model == self.empty_model: - return lambda value_and_type, index=index: self.setData(index, join_value_and_type(*value_and_type)) - id_ = self.item_at_row(index.row()) - value_field = {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type] - return lambda value_and_type, sub_model=sub_model, id_=id_: sub_model.update_items_in_db( - [{"id": id_, value_field: join_value_and_type(*value_and_type)}] - ) - def get_entity_class_id(self, index, db_map): - entity_class_name_key = {"object_class": "object_class_name", "relationship_class": "relationship_class_name"}[ - self.entity_class_type - ] - entity_class_name = index.sibling(index.row(), self.header.index(entity_class_name_key)).data() - entity_class = self.db_mngr.get_item_by_field(db_map, self.entity_class_type, "name", entity_class_name) + entity_class_name = index.sibling(index.row(), self.header.index("entity_class_name")).data() + entity_class = db_map.get_item("entity_class", name=entity_class_name) return entity_class.get("id") def filter_by(self, rows_per_column): @@ -541,56 +447,18 @@ def filter_excluding(self, rows_per_column): menu.set_filter_rejected_values(rejected_values) -class CompoundObjectParameterMixin: - """Implements the interface for populating and filtering a compound object parameter model.""" - - @property - def entity_class_type(self): - return "object_class" - - -class CompoundRelationshipParameterMixin: - """Implements the interface for populating and filtering a compound relationship parameter model.""" - - @property - def entity_class_type(self): - return "relationship_class" - - -class CompoundParameterDefinitionMixin: - """Handles signals from db mngr for parameter_definition models.""" - - @property - def item_type(self): - return "parameter_definition" - - -class CompoundParameterValueMixin: - """Handles signals from db mngr for parameter_value models.""" +class FilterEntityAlternativeMixin: + """Provides the interface to filter by entity and alternative.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._filter_entity_ids = dict() - self._filter_alternative_ids = dict() + self._filter_entity_ids = {} + self._filter_alternative_ids = {} def init_model(self): super().init_model() - self._filter_entity_ids = dict() - self._filter_alternative_ids = dict() - - @property - def item_type(self): - return "parameter_value" - - @property - def entity_type(self): - """Returns the entity type, either 'object' or 'relationship' - Used by update_single_main_filter. - - Returns: - str - """ - raise NotImplementedError() + self._filter_entity_ids = {} + self._filter_alternative_ids = {} def set_filter_entity_ids(self, entity_ids): self._filter_entity_ids = entity_ids @@ -611,28 +479,67 @@ def _create_single_model(self, db_map, entity_class_id, committed): return model -class CompoundObjectParameterDefinitionModel( - CompoundObjectParameterMixin, CompoundParameterDefinitionMixin, CompoundParameterModel -): - """A model that concatenates several single object parameter_definition models - and one empty object parameter_definition model. - """ +class EditParameterValueMixin: + """Provides the interface to edit values via ParameterValueEditor.""" - def _make_header(self): - return ["object_class_name", "parameter_name", "value_list_name", "default_value", "description", "database"] + def index_name(self, index): + """Generates a name for data at given index. + + Args: + index (QModelIndex): index to model + + Returns: + str: label identifying the data + """ + item = self.db_item(index) + if item is None: + return "" + database = self.index(index.row(), self.columnCount() - 1).data() + if self.item_type == "parameter_definition": + parameter_name = item["name"] + names = [item["entity_class_name"]] + elif self.item_type == "parameter_value": + parameter_name = item["parameter_name"] + names = list(item["entity_byname"]) + else: + raise ValueError( + f"invalid item_type: expected parameter_definition or parameter_value, got {self.item_type}" + ) + alternative_name = {"parameter_definition": lambda x: None, "parameter_value": lambda x: x["alternative_name"]}[ + self.item_type + ](item) + return parameter_identifier(database, parameter_name, names, alternative_name) + + def get_set_data_delayed(self, index): + """Returns a function that ParameterValueEditor can call to set data for the given index at any later time, + even if the model changes. + + Args: + index (QModelIndex) + + Returns: + function + """ + sub_model = self.sub_model_at_row(index.row()) + if sub_model == self.empty_model: + return lambda value_and_type, index=index: self.setData(index, join_value_and_type(*value_and_type)) + id_ = self.item_at_row(index.row()) + value_field = {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type] + return lambda value_and_type, sub_model=sub_model, id_=id_: sub_model.update_items_in_db( + [{"id": id_, value_field: join_value_and_type(*value_and_type)}] + ) -class CompoundRelationshipParameterDefinitionModel( - CompoundRelationshipParameterMixin, CompoundParameterDefinitionMixin, CompoundParameterModel -): - """A model that concatenates several single relationship parameter_definition models - and one empty relationship parameter_definition model. - """ +class CompoundParameterDefinitionModel(EditParameterValueMixin, CompoundModelBase): + """A model that concatenates several single parameter_definition models and one empty parameter_definition model.""" + + @property + def item_type(self): + return "parameter_definition" def _make_header(self): return [ - "relationship_class_name", - "object_class_name_list", + "entity_class_name", "parameter_name", "value_list_name", "default_value", @@ -640,33 +547,30 @@ def _make_header(self): "database", ] + @property + def field_map(self): + return {"parameter_name": "name", "value_list_name": "parameter_value_list_name"} -class CompoundObjectParameterValueModel( - CompoundObjectParameterMixin, CompoundParameterValueMixin, CompoundParameterModel -): - """A model that concatenates several single object parameter_value models - and one empty object parameter_value model. - """ - - def _make_header(self): - return ["object_class_name", "object_name", "parameter_name", "alternative_name", "value", "database"] + @property + def _single_model_type(self): + return SingleParameterDefinitionModel @property - def entity_type(self): - return "object" + def _empty_model_type(self): + return EmptyParameterDefinitionModel + +class CompoundParameterValueModel(FilterEntityAlternativeMixin, EditParameterValueMixin, CompoundModelBase): + """A model that concatenates several single parameter_value models and one empty parameter_value model.""" -class CompoundRelationshipParameterValueModel( - CompoundRelationshipParameterMixin, CompoundParameterValueMixin, CompoundParameterModel -): - """A model that concatenates several single relationship parameter_value models - and one empty relationship parameter_value model. - """ + @property + def item_type(self): + return "parameter_value" def _make_header(self): return [ - "relationship_class_name", - "object_name_list", + "entity_class_name", + "entity_byname", "parameter_name", "alternative_name", "value", @@ -674,5 +578,36 @@ def _make_header(self): ] @property - def entity_type(self): - return "relationship" + def field_map(self): + return {"parameter_name": "parameter_definition_name"} + + @property + def _single_model_type(self): + return SingleParameterValueModel + + @property + def _empty_model_type(self): + return EmptyParameterValueModel + + +class CompoundEntityAlternativeModel(FilterEntityAlternativeMixin, CompoundModelBase): + @property + def item_type(self): + return "entity_alternative" + + def _make_header(self): + return [ + "entity_class_name", + "entity_byname", + "alternative_name", + "active", + "database", + ] + + @property + def _single_model_type(self): + return SingleEntityAlternativeModel + + @property + def _empty_model_type(self): + return EmptyEntityAlternativeModel diff --git a/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py new file mode 100644 index 000000000..26c2ffa05 --- /dev/null +++ b/spinetoolbox/spine_db_editor/mvcmodels/empty_models.py @@ -0,0 +1,296 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Empty models for parameter definitions and values.""" +from PySide6.QtCore import Qt +from ...mvcmodels.empty_row_model import EmptyRowModel +from .single_and_empty_model_mixins import SplitValueAndTypeMixin, MakeEntityOnTheFlyMixin +from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE +from ...helpers import rows_to_row_count_tuples, DB_ITEM_SEPARATOR + + +class EmptyModelBase(EmptyRowModel): + """Base class for all empty models that go in a CompoundModelBase subclass.""" + + def __init__(self, parent): + """ + Args: + parent (CompoundModelBase): the parent model + """ + super().__init__(parent, parent.header) + self.db_mngr = parent.db_mngr + self.db_map = None + self.entity_class_id = None + + @property + def item_type(self): + raise NotImplementedError() + + @property + def field_map(self): + return self._parent.field_map + + def add_items_to_db(self, db_map_data): + """Add items to db. + + Args: + db_map_data (dict): mapping DiffDatabaseMapping instance to list of items + """ + db_map_items = {} + db_map_error_log = {} + for db_map, items in db_map_data.items(): + for item in items: + item_to_add, errors = self._convert_to_db(item) + self._autocomplete_row(db_map, item_to_add) + if self._check_item(item_to_add): + db_map_items.setdefault(db_map, []).append(item_to_add) + if errors: + db_map_error_log.setdefault(db_map, []).extend(errors) + if any(db_map_items.values()): + self._do_add_items_to_db(db_map_items) + if db_map_error_log: + self.db_mngr.error_msg.emit(db_map_error_log) + + def _make_unique_id(self, item): + """Returns a unique id for the given model item (name-based). Used by handle_items_added to identify + which rows have been added and thus need to be removed.""" + raise NotImplementedError() + + @property + def can_be_filtered(self): + return False + + def accepted_rows(self): + return range(self.rowCount()) + + def db_item(self, _index): # pylint: disable=no-self-use + return None + + def item_id(self, _row): # pylint: disable=no-self-use + return None + + def handle_items_added(self, db_map_data): + """Runs when parameter definitions or values are added. + Finds and removes model items that were successfully added to the db.""" + added_ids = set() + for db_map, items in db_map_data.items(): + for item in items: + database = db_map.codename + unique_id = (database, *self._make_unique_id(item)) + added_ids.add(unique_id) + removed_rows = [] + for row in range(self.rowCount()): + item = self._make_item(row) + database = item.get("database") + unique_id = (database, *self._make_unique_id(self._convert_to_db(item)[0])) + if unique_id in added_ids: + removed_rows.append(row) + for row, count in sorted(rows_to_row_count_tuples(removed_rows), reverse=True): + self.removeRows(row, count) + + def batch_set_data(self, indexes, data): + """Sets data for indexes in batch. If successful, add items to db.""" + if not super().batch_set_data(indexes, data): + return False + rows = {ind.row() for ind in indexes} + db_map_data = self._make_db_map_data(rows) + self.add_items_to_db(db_map_data) + return True + + def _autocomplete_row(self, db_map, item): + """Fills in entity_class_name whenever other selections make it obvious.""" + candidates = self._entity_class_name_candidates(db_map, item) + row = item.pop("row", None) + if len(candidates) == 1: + entity_class_name = candidates[0] + item["entity_class_name"] = entity_class_name + self._main_data[row][self.header.index("entity_class_name")] = entity_class_name + + def _entity_class_name_candidates(self, db_map, item): + raise NotImplementedError() + + def _make_item(self, row): + return dict(zip(self.header, self._main_data[row]), row=row) + + def _make_db_map_data(self, rows): + """ + Returns model data grouped by database map. + + Args: + rows (set): group data from these rows + + Returns: + dict: mapping DiffDatabaseMapping instance to list of items + """ + items = [self._make_item(row) for row in rows] + db_map_data = {} + for item in items: + database = item.pop("database") + db_map = next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) + if not db_map: + continue + item = {k: v for k, v in item.items() if v is not None} + db_map_data.setdefault(db_map, []).append(item) + return db_map_data + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if role == DB_MAP_ROLE: + database = self.data(index, Qt.ItemDataRole.DisplayRole) + return next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) + return super().data(index, role) + + +class ParameterMixin: + @property + def value_field(self): + return {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type] + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if self.header[index.column()] == self.value_field and role in ( + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.ToolTipRole, + Qt.ItemDataRole.TextAlignmentRole, + PARSED_ROLE, + ): + data = super().data(index, role=Qt.ItemDataRole.EditRole) + return self.db_mngr.get_value_from_data(data, role) + return super().data(index, role) + + @staticmethod + def _entity_class_name_candidates_by_parameter(db_map, item): + return [ + x["entity_class_name"] + for x in db_map.get_items("parameter_definition", name=item.get("parameter_definition_name")) + ] + + +class EntityMixin: + def _do_add_items_to_db(self, db_map_items): + raise NotImplementedError() + + def add_items_to_db(self, db_map_data): + """Overriden to add entities on the fly first.""" + db_map_entities = {} + db_map_error_log = {} + for db_map, items in db_map_data.items(): + for item in items: + item_to_add, _ = self._convert_to_db(item) + self._autocomplete_row(db_map, item_to_add) + entity, errors = self._make_entity_on_the_fly(item, db_map) + if entity: + entities = db_map_entities.setdefault(db_map, []) + if entity not in entities: + entities.append(entity) + if errors: + db_map_error_log.setdefault(db_map, []).extend(errors) + if any(db_map_entities.values()): + self.db_mngr.add_entities(db_map_entities) + if db_map_error_log: + self.db_mngr.error_msg.emit(db_map_error_log) + super().add_items_to_db(db_map_data) + + def _make_item(self, row): + item = super()._make_item(row) + byname = item["entity_byname"] + item["entity_byname"] = tuple(byname.split(DB_ITEM_SEPARATOR)) if byname else () + return item + + @staticmethod + def _entity_class_name_candidates_by_entity(db_map, item): + return [x["entity_class_name"] for x in db_map.get_items("entity", entity_byname=item.get("entity_byname"))] + + +class EmptyParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, EmptyModelBase): + """An empty parameter_definition model.""" + + @property + def item_type(self): + return "parameter_definition" + + def _make_unique_id(self, item): + return tuple(item.get(x) for x in ("entity_class_name", "name")) + + @staticmethod + def _check_item(item): + """Checks if a db item is ready to be inserted.""" + return item.get("entity_class_name") and item.get("name") + + def _entity_class_name_candidates(self, db_map, item): + return [] + + def _do_add_items_to_db(self, db_map_items): + self.db_mngr.add_parameter_definitions(db_map_items) + + +class EmptyParameterValueModel( + MakeEntityOnTheFlyMixin, SplitValueAndTypeMixin, ParameterMixin, EntityMixin, EmptyModelBase +): + """An empty parameter_value model.""" + + @property + def item_type(self): + return "parameter_value" + + @staticmethod + def _check_item(item): + """Checks if a db item is ready to be inserted.""" + return all( + key in item + for key in ( + "entity_class_name", + "entity_byname", + "parameter_definition_name", + "alternative_name", + "value", + "type", + ) + ) + + def _make_unique_id(self, item): + return tuple( + item.get(x) for x in ("entity_class_name", "entity_byname", "parameter_definition_name", "alternative_name") + ) + + def _do_add_items_to_db(self, db_map_items): + self.db_mngr.add_parameter_values(db_map_items) + + def _entity_class_name_candidates(self, db_map, item): + candidates_by_parameter = self._entity_class_name_candidates_by_parameter(db_map, item) + candidates_by_entity = self._entity_class_name_candidates_by_entity(db_map, item) + if not candidates_by_parameter: + return candidates_by_entity + if not candidates_by_entity: + return candidates_by_parameter + return list( + set(self._entity_class_name_candidates_by_parameter(db_map, item)) + & set(self._entity_class_name_candidates_by_entity(db_map, item)) + ) + + +class EmptyEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, EmptyModelBase): + @property + def item_type(self): + return "entity_alternative" + + @staticmethod + def _check_item(item): + """Checks if a db item is ready to be inserted.""" + return all(key in item for key in ("entity_class_name", "entity_byname", "alternative_name", "active")) + + def _make_unique_id(self, item): + return tuple(item.get(x) for x in ("entity_class_name", "entity_byname", "alternative_name")) + + def _do_add_items_to_db(self, db_map_items): + self.db_mngr.add_entity_alternatives(db_map_items) + + def _entity_class_name_candidates(self, db_map, item): + return self._entity_class_name_candidates_by_entity(db_map, item) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/empty_parameter_models.py b/spinetoolbox/spine_db_editor/mvcmodels/empty_parameter_models.py deleted file mode 100644 index 30203c79a..000000000 --- a/spinetoolbox/spine_db_editor/mvcmodels/empty_parameter_models.py +++ /dev/null @@ -1,349 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Empty models for parameter definitions and values. -""" -from PySide6.QtCore import Qt -from ...mvcmodels.empty_row_model import EmptyRowModel -from .parameter_mixins import ( - FillInParameterNameMixin, - MakeRelationshipOnTheFlyMixin, - InferEntityClassIdMixin, - FillInAlternativeIdMixin, - FillInParameterDefinitionIdsMixin, - FillInEntityIdsMixin, - FillInEntityClassIdMixin, - FillInValueListIdMixin, -) -from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE -from ...helpers import rows_to_row_count_tuples, DB_ITEM_SEPARATOR - - -class EmptyParameterModel(EmptyRowModel): - """An empty parameter model.""" - - def __init__(self, parent, header, db_mngr): - """Initialize class. - - Args: - parent (Object): the parent object, typically a CompoundParameterModel - header (list): list of field names for the header - db_mngr (SpineDBManager) - """ - super().__init__(parent, header) - self.db_mngr = db_mngr - self.db_map = None - self.entity_class_id = None - - @property - def item_type(self): - """The item type, either 'parameter_value' or 'parameter_definition', required by the value_field property.""" - raise NotImplementedError() - - @property - def entity_class_type(self): - """Either 'object_class' or 'relationship_class'.""" - raise NotImplementedError() - - @property - def entity_class_id_key(self): - return {"object_class": "object_class_id", "relationship_class": "relationship_class_id"}[ - self.entity_class_type - ] - - @property - def entity_class_name_key(self): - return {"object_class": "object_class_name", "relationship_class": "relationship_class_name"}[ - self.entity_class_type - ] - - @property - def can_be_filtered(self): - return False - - @property - def value_field(self): - return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type] - - def accepted_rows(self): - return range(self.rowCount()) - - def db_item(self, _index): # pylint: disable=no-self-use - return None - - def item_id(self, _row): # pylint: disable=no-self-use - return None - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - if role == DB_MAP_ROLE: - database = self.data(index, Qt.ItemDataRole.DisplayRole) - return next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) - if self.header[index.column()] == self.value_field and role in ( - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.ToolTipRole, - Qt.TextAlignmentRole, - PARSED_ROLE, - ): - data = super().data(index, role=Qt.ItemDataRole.EditRole) - return self.db_mngr.get_value_from_data(data, role) - return super().data(index, role) - - def _make_unique_id(self, item): - """Returns a unique id for the given model item (name-based). Used by handle_items_added.""" - return (item.get(self.entity_class_name_key), item.get("parameter_name")) - - def handle_items_added(self, db_map_data): - """Runs when parameter definitions or values are added. - Finds and removes model items that were successfully added to the db.""" - added_ids = set() - for db_map, items in db_map_data.items(): - for item in items: - database = db_map.codename - unique_id = (database, *self._make_unique_id(item)) - added_ids.add(unique_id) - removed_rows = [] - for row in range(self.rowCount()): - item = self._make_item(row) - database = item.get("database") - unique_id = (database, *self._make_unique_id(item)) - if unique_id in added_ids: - removed_rows.append(row) - for row, count in sorted(rows_to_row_count_tuples(removed_rows), reverse=True): - self.removeRows(row, count) - - def batch_set_data(self, indexes, data): - """Sets data for indexes in batch. If successful, add items to db.""" - if not super().batch_set_data(indexes, data): - return False - rows = {ind.row() for ind in indexes} - db_map_data = self._make_db_map_data(rows) - self.add_items_to_db(db_map_data) - return True - - def add_items_to_db(self, db_map_data): - """Add items to db. - - Args: - db_map_data (dict): mapping DiffDatabaseMapping instance to list of items - """ - raise NotImplementedError() - - def _make_item(self, row): - return dict(zip(self.header, self._main_data[row]), row=row) - - def _make_db_map_data(self, rows): - """ - Returns model data grouped by database map. - - Args: - rows (set): group data from these rows - - Returns: - dict: mapping DiffDatabaseMapping instance to list of items - """ - items = [self._make_item(row) for row in rows] - db_map_data = dict() - for item in items: - database = item.pop("database") - db_map = next(iter(x for x in self.db_mngr.db_maps if x.codename == database), None) - if not db_map: - continue - item = {k: v for k, v in item.items() if v is not None} - db_map_data.setdefault(db_map, []).append(item) - return db_map_data - - -class EmptyParameterDefinitionModel( - FillInValueListIdMixin, FillInEntityClassIdMixin, FillInParameterNameMixin, EmptyParameterModel -): - """An empty parameter_definition model.""" - - @property - def item_type(self): - return "parameter_definition" - - @property - def entity_class_type(self): - """See base class.""" - raise NotImplementedError() - - def add_items_to_db(self, db_map_data): - """See base class.""" - self.build_lookup_dictionary(db_map_data) - db_map_param_def = dict() - db_map_error_log = dict() - for db_map, items in db_map_data.items(): - for item in items: - def_item, errors = self._convert_to_db(item, db_map) - if self._check_item(def_item): - db_map_param_def.setdefault(db_map, []).append(def_item) - if errors: - db_map_error_log.setdefault(db_map, []).extend(errors) - if any(db_map_param_def.values()): - self.db_mngr.add_parameter_definitions(db_map_param_def) - if db_map_error_log: - self.db_mngr.error_msg.emit(db_map_error_log) - - def _check_item(self, item): - """Checks if a db item is ready to be inserted.""" - return self.entity_class_id_key in item and "name" in item - - -class EmptyObjectParameterDefinitionModel(EmptyParameterDefinitionModel): - """An empty object parameter_definition model.""" - - @property - def entity_class_type(self): - return "object_class" - - -class EmptyRelationshipParameterDefinitionModel(EmptyParameterDefinitionModel): - """An empty relationship parameter_definition model.""" - - @property - def entity_class_type(self): - return "relationship_class" - - def flags(self, index): - """Additional hack to make the object_class_name_list column non-editable.""" - flags = super().flags(index) - if self.header[index.column()] == "object_class_name_list": - flags &= ~Qt.ItemIsEditable - return flags - - -class EmptyParameterValueModel( - InferEntityClassIdMixin, - FillInAlternativeIdMixin, - FillInParameterDefinitionIdsMixin, - FillInEntityIdsMixin, - FillInEntityClassIdMixin, - EmptyParameterModel, -): - """An empty parameter_value model.""" - - @property - def item_type(self): - return "parameter_value" - - @property - def entity_type(self): - """Either 'object' or "relationship'.""" - raise NotImplementedError() - - @property - def entity_id_key(self): - return {"object": "object_id", "relationship": "relationship_id"}[self.entity_type] - - @property - def entity_name_key(self): - return {"object": "object_name", "relationship": "object_name_list"}[self.entity_type] - - @property - def entity_name_key_in_cache(self): - return {"object": "name", "relationship": "object_name_list"}[self.entity_type] - - def _make_unique_id(self, item): - """Returns a unique id for the given model item (name-based). Used by handle_items_added.""" - return (*super()._make_unique_id(item), item.get("alternative_name")) - - def add_items_to_db(self, db_map_data): - """See base class.""" - self.build_lookup_dictionary(db_map_data) - db_map_param_val = dict() - db_map_error_log = dict() - for db_map, items in db_map_data.items(): - for item in items: - param_val, errors = self._convert_to_db(item, db_map) - if self._check_item(db_map, param_val): - db_map_param_val.setdefault(db_map, []).append(param_val) - if errors: - db_map_error_log.setdefault(db_map, []).extend(errors) - if any(db_map_param_val.values()): - self.db_mngr.add_parameter_values(db_map_param_val) - if db_map_error_log: - self.db_mngr.error_msg.emit(db_map_error_log) - - def _check_item(self, db_map, item): - """Checks if a db item is ready to be inserted.""" - return ( - self.entity_class_id_key in item - and self.entity_id_key in item - and "parameter_definition_id" in item - and "alternative_id" in item - and "value" in item - ) - - -class EmptyObjectParameterValueModel(EmptyParameterValueModel): - """An empty object parameter_value model.""" - - @property - def entity_class_type(self): - return "object_class" - - @property - def entity_type(self): - return "object" - - def _make_unique_id(self, item): - return (*super()._make_unique_id(item), item.get("name")) - - -class EmptyRelationshipParameterValueModel(MakeRelationshipOnTheFlyMixin, EmptyParameterValueModel): - """An empty relationship parameter_value model.""" - - _add_entities_on_the_fly = True - - @property - def entity_class_type(self): - return "relationship_class" - - @property - def entity_type(self): - return "relationship" - - def _make_unique_id(self, item): - object_name_list = item.get("object_name_list") - return ( - *super()._make_unique_id(item), - DB_ITEM_SEPARATOR.join(object_name_list) if object_name_list is not None else None, - ) - - def _make_item(self, row): - item = super()._make_item(row) - if item["object_name_list"]: - item["object_name_list"] = tuple(item["object_name_list"].split(DB_ITEM_SEPARATOR)) - return item - - def add_items_to_db(self, db_map_data): - """See base class.""" - # Call the super method to add whatever is ready. - # This will fill the relationship_class_name as a side effect - super().add_items_to_db(db_map_data) - # Now we try to add relationships - self.build_lookup_dictionaries(db_map_data) - db_map_relationships = dict() - db_map_error_log = dict() - for db_map, items in db_map_data.items(): - for item in items: - relationship, err = self._make_relationship_on_the_fly(item, db_map) - if relationship: - db_map_relationships.setdefault(db_map, []).append(relationship) - if err: - db_map_error_log.setdefault(db_map, []).extend(err) - if any(db_map_relationships.values()): - self.db_mngr.add_relationships(db_map_relationships) - # Something might have become ready after adding the relationship(s), so we do one more pass - super().add_items_to_db(db_map_data) - if db_map_error_log: - self.db_mngr.error_msg.emit(db_map_error_log) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py index 046f81c6c..ee9b633cb 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_item.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,21 +10,44 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes to represent entities in a tree. -""" - +"""Classes to represent entities in a tree.""" from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QBrush, QIcon - -from spinetoolbox.helpers import DB_ITEM_SEPARATOR -from spinetoolbox.fetch_parent import FlexibleFetchParent +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, plain_to_tool_tip +from spinetoolbox.fetch_parent import FlexibleFetchParent, FetchIndex from .multi_db_tree_item import MultiDBTreeItem -class EntityRootItem(MultiDBTreeItem): +class EntityClassIndex(FetchIndex): + def process_item(self, item, db_map): + class_id = item["class_id"] + self.setdefault(db_map, {}).setdefault(class_id, []).append(item) + + +class EntityGroupIndex(FetchIndex): + def process_item(self, item, db_map): + group_id = item["group_id"] + self.setdefault(db_map, {}).setdefault(group_id, []).append(item) + + +class EntityIndex(FetchIndex): + def process_item(self, item, db_map): + element_id_list = item["element_id_list"] + for el_id in element_id_list: + self.setdefault(db_map, {}).setdefault(el_id, []).append(item) + + +class EntityTreeRootItem(MultiDBTreeItem): item_type = "root" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._has_children_initially = True + + @property + def visible_children(self): + return [x for x in self.children if not x.is_hidden()] + @property def display_id(self): """See super class.""" @@ -42,55 +66,80 @@ def set_data(self, column, value, role): """See base class.""" return False - -class ObjectTreeRootItem(EntityRootItem): - """An object tree root item.""" - - item_type = "root" - @property def child_item_class(self): """Returns ObjectClassItem.""" - return ObjectClassItem + return EntityClassItem + def _polish_children(self, children): + """See base class.""" + db_map_entity_class_ids = { + db_map: {x["class_id"] for x in self.db_mngr.get_items(db_map, "entity")} for db_map in self.db_maps + } + for child in children: + child.set_has_children_initially( + any(child.db_map_id(db_map) in db_map_entity_class_ids.get(db_map, ()) for db_map in child.db_maps) + ) -class RelationshipTreeRootItem(EntityRootItem): - """A relationship tree root item.""" - item_type = "root" +class EntityClassItem(MultiDBTreeItem): + """An entity_class item.""" + + visual_key = ["name", "dimension_name_list", "superclass_name"] + item_type = "entity_class" + _fetch_index = EntityClassIndex() + + @property + def display_icon(self): + """Returns class icon.""" + return self.db_mngr.entity_class_icon(self.first_db_map, self.db_map_id(self.first_db_map)) @property def child_item_class(self): - """Returns RelationshipClassItem.""" - return RelationshipClassItem + return EntityItem + def is_hidden(self): + return self.model.hide_empty_classes and not self.has_children() -class EntityClassItem(MultiDBTreeItem): - """An entity_class item.""" + @property + def _children_sort_key(self): + """Reimplemented so groups are above non-groups.""" + return lambda item: (not item.is_group, item.display_id) + + def default_parameter_data(self): + """Return data to put as default in a parameter table when this item is selected.""" + return dict(entity_class_name=self.name, database=self.first_db_map.codename) @property - def display_icon(self): - """Returns class icon.""" - return self._display_icon() + def display_data(self): + """Returns the name for display.""" + name = self.name + superclass_name = self.db_map_data_field(self.first_db_map, "superclass_name") + if superclass_name: + name += f"({superclass_name})" + return name - def _display_icon(self, for_group=False): - return self.db_mngr.entity_class_icon( - self.first_db_map, self.item_type, self.db_map_id(self.first_db_map), for_group=for_group - ) + @property + def has_dimensions(self): + return bool(self.db_map_data_field(self.first_db_map, "dimension_id_list")) def data(self, column, role=Qt.ItemDataRole.DisplayRole): """Returns data for given column and role.""" if role == Qt.ItemDataRole.ToolTipRole: - return self.db_map_data_field(self.first_db_map, "description") - if role == Qt.ItemDataRole.FontRole and column == 0: - bold_font = QFont() - bold_font.setBold(True) - return bold_font - if role == Qt.ForegroundRole and column == 0: - if not self.has_children(): - return QBrush(Qt.gray) + return plain_to_tool_tip(self.db_map_data_field(self.first_db_map, "description")) + if column == 0: + if role == Qt.ItemDataRole.FontRole: + bold_font = QFont() + bold_font.setBold(True) + return bold_font + if role == Qt.ItemDataRole.ForegroundRole: + if not self.has_children(): + return QBrush(Qt.gray) return super().data(column, role) + def _key_for_index(self, db_map): + return self.db_map_id(db_map) + def accepts_item(self, item, db_map): return item["class_id"] == self.db_map_id(db_map) @@ -98,286 +147,181 @@ def set_data(self, column, value, role): """See base class.""" return False + def _polish_children(self, children): + """See base class.""" + db_map_entity_element_ids = { + db_map: {el_id for ent in self.db_mngr.get_items(db_map, "entity") for el_id in ent["element_id_list"]} + for db_map in self.db_maps + } + for child in children: + child.set_has_children_initially( + any(child.db_map_id(db_map) in db_map_entity_element_ids.get(db_map, ()) for db_map in child.db_maps) + ) -class ObjectClassItem(EntityClassItem): - """An object_class item.""" - item_type = "object_class" +class EntityItem(MultiDBTreeItem): + """An entity item.""" - def __init__(self, *args, **kwargs): + visual_key = ["entity_class_name", "entity_byname"] + item_type = "entity" + _fetch_index = EntityIndex() + _entity_group_index = EntityGroupIndex() + + def __init__(self, *args, is_member=False, **kwargs): super().__init__(*args, **kwargs) + self._is_group = False + self._is_member = is_member self._entity_group_fetch_parent = FlexibleFetchParent( "entity_group", accepts_item=self._accepts_entity_group_item, handle_items_added=self._handle_entity_group_items_added, - handle_items_updated=self._handle_entity_group_items_updated, + handle_items_removed=self._handle_entity_group_items_removed, + index=self._entity_group_index, + key_for_index=self._key_for_entity_group_index, owner=self, ) @property - def child_item_class(self): - """Returns ObjectItem.""" - return ObjectItem - - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - return dict(object_class_name=self.display_data, database=self.first_db_map.codename) - - @property - def _children_sort_key(self): - """Reimplemented so groups are above non-groups.""" - return lambda item: (not item.is_group, item.display_id) - - def _can_fetch_more_entity_groups(self): - result = False - for db_map in self.db_maps: - result |= self.db_mngr.can_fetch_more(db_map, self._entity_group_fetch_parent) - return result - - def can_fetch_more(self): - result = self._can_fetch_more_entity_groups() - result |= super().can_fetch_more() - return result - - def _fetch_more_entity_groups(self): - for db_map in self.db_maps: - self.db_mngr.fetch_more(db_map, self._entity_group_fetch_parent) - - def fetch_more(self): - self._fetch_more_entity_groups() - super().fetch_more() - - def _accepts_entity_group_item(self, item, db_map): - return item["class_id"] == self.db_map_id(db_map) - - def _handle_entity_group_items_added(self, db_map_data): - self._fetch_more_entity_groups() - db_map_ids = {db_map: [x["group_id"] for x in data] for db_map, data in db_map_data.items()} - self.update_children_by_id(db_map_ids, is_group=True) - - def _handle_entity_group_items_updated(self, db_map_data): - db_map_ids = {db_map: [x["group_id"] for x in data] for db_map, data in db_map_data.items()} - self.update_children_by_id(db_map_ids, is_group=True) - - def tear_down(self): - super().tear_down() - self._entity_group_fetch_parent.set_obsolete(True) - - def revitalize(self): - """See base class""" - super().revitalize() - self._entity_group_fetch_parent.set_obsolete(False) - - -class RelationshipClassItem(EntityClassItem): - """A relationship_class item.""" - - visual_key = ["name", "object_class_name_list"] - item_type = "relationship_class" + def is_group(self): + if not self._is_group and self._can_fetch_more_entity_groups(): + self._fetch_more_entity_groups() + return self._is_group @property def child_item_class(self): - """Returns RelationshipItem.""" - return RelationshipItem - - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - return dict(relationship_class_name=self.display_data, database=self.first_db_map.codename) - - -class ObjectRelationshipClassItem(RelationshipClassItem): - def set_data(self, column, value, role): - """See base class.""" - return False - - def accepts_item(self, item, db_map): - if not super().accepts_item(item, db_map): - return False - object_id = self.parent_item.db_map_id(db_map) - return object_id in item["object_id_list"] - - -class MembersItem(EntityClassItem): - """An item to hold members of a group.""" - - item_type = "members" - - @property - def display_id(self): - # Return an empty tuple so we never insert anything above this item (see _insert_children_sorted) - return () + """Child class is always :class:`EntityItem`.""" + return EntityItem @property - def display_data(self): - return "members" - - def db_map_data(self, db_map): - """Returns data for this item as if it was indeed an object class.""" - id_ = self.db_map_id(db_map) - return self.db_mngr.get_item(db_map, "object_class", id_) - - def _display_icon(self, for_group=False): - """Returns icon for this item as if it was indeed an object class.""" + def display_icon(self): + """Returns corresponding class icon.""" return self.db_mngr.entity_class_icon( - self.first_db_map, "object_class", self.db_map_id(self.first_db_map), for_group=False + self.first_db_map, self.db_map_data_field(self.first_db_map, "class_id"), for_group=self.is_group ) - def accepts_item(self, item, db_map): - return item["group_id"] == self.parent_item.db_map_id(db_map) - @property - def child_item_class(self): - """Returns MemberObjectItem.""" - return MemberObjectItem - - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - return dict() - - def data(self, column, role=Qt.ItemDataRole.DisplayRole): - """Returns data for given column and role.""" - if role == Qt.ItemDataRole.FontRole and column == 0: - bold_font = QFont() - bold_font.setBold(True) - return bold_font - return super().data(column, role) + def element_name_list(self): + return self.db_map_data_field(self.first_db_map, "element_name_list", default=()) + @property + def element_byname_list(self): + return self.db_map_data_field(self.first_db_map, "element_byname_list", default=()) -class EntityItem(MultiDBTreeItem): - """An entity item.""" + @property + def byname(self): + return self.db_map_data_field(self.first_db_map, "entity_byname", default=()) - def __init__(self, *args, is_group=False, **kwargs): - super().__init__(*args, **kwargs) - self.is_group = is_group - self.has_members_item = False + @property + def entity_class_name(self): + return self.db_map_data_field(self.first_db_map, "entity_class_name", default="") - def update(self, is_group=False): - self.is_group = is_group + @property + def entity_class_key(self): + return tuple( + self.db_map_data_field(self.first_db_map, field) for field in ("entity_class_name", "dimension_name_list") + ) - def should_be_merged(self): - return self.is_group + @property + def display_data(self): + element_byname_list = self.element_byname_list + if element_byname_list: + element_byname_list = [ + x + if not isinstance(self.parent_item, EntityItem) or x != self.parent_item.byname + else ["\u066D"] * len(x) + for x in element_byname_list + ] + return DB_ITEM_SEPARATOR.join([DB_ITEM_SEPARATOR.join(x) for x in element_byname_list]) + return self.name @property - def display_icon(self): - """Returns corresponding class icon.""" - return self.parent_item._display_icon(for_group=self.is_group) + def edit_data(self): + return DB_ITEM_SEPARATOR.join(self.byname) def data(self, column, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.ToolTipRole: - return self.db_map_data_field(self.first_db_map, "description") + return plain_to_tool_tip(self.db_map_data_field(self.first_db_map, "description")) return super().data(column, role) def set_data(self, column, value, role): """See base class.""" return False - def _can_fetch_members_item(self): - return self.is_group and not self.has_members_item - - def _fetch_members_item(self): - if self._can_fetch_members_item(): - self.has_members_item = True - # Insert members item. Note that we pass the db_map_ids of the parent object class item - self.insert_children(0, [MembersItem(self.model, self.parent_item.db_map_ids.copy())]) - - def can_fetch_more(self): - return super().can_fetch_more() or self._can_fetch_members_item() - - def fetch_more(self): - super().fetch_more() - self._fetch_members_item() - - -class ObjectItem(EntityItem): - """An object item.""" - - item_type = "object" - - @property - def child_item_class(self): - """Child class is always :class:`ObjectRelationshipClassItem`.""" - return ObjectRelationshipClassItem - def default_parameter_data(self): """Return data to put as default in a parameter table when this item is selected.""" return dict( - object_class_name=self.db_map_data_field(self.first_db_map, "class_name"), - object_name=self.display_data, + entity_class_name=self.db_map_data_field(self.first_db_map, "entity_class_name"), + entity_byname=DB_ITEM_SEPARATOR.join(self.db_map_data_field(self.first_db_map, "entity_byname")), database=self.first_db_map.codename, ) - def accepts_item(self, item, db_map): - if not super().accepts_item(item, db_map): - return False - object_class_id = self.db_map_data_field(db_map, 'class_id') - return object_class_id in item["object_class_id_list"] - - -class MemberObjectItem(ObjectItem): - """A member object item.""" - - item_type = "entity_group" - visual_key = ["member_name"] - - @property - def display_icon(self): - return self.parent_item.display_icon + def is_valid(self): + """See base class. - @property - def display_data(self): - """ "Returns the name for display.""" - return self.db_map_data_field(self.first_db_map, "member_name") + Additionally, checks that the parent entity (if any) is still an element in this entity. + """ + if not super().is_valid(): + return False + if self.parent_item.item_type == "entity_class": + return True + if self._is_member: + return True + return self.parent_item.name in self.element_name_list - def has_children(self): - return False + def _can_fetch_more_entity_groups(self): + result = False + for db_map in self.db_maps: + result |= self.db_mngr.can_fetch_more(db_map, self._entity_group_fetch_parent) + return result def can_fetch_more(self): - return False - - -class RelationshipItem(EntityItem): - """A relationship item.""" - - visual_key = ["name", "object_name_list"] - item_type = "relationship" + result = self._can_fetch_more_entity_groups() + result |= super().can_fetch_more() + return result - def __init__(self, *args, **kwargs): - """Overridden method to make sure we never try to fetch this item.""" - super().__init__(*args, **kwargs) - self._fetched = True + def _fetch_more_entity_groups(self): + for db_map in self.db_maps: + self.db_mngr.fetch_more(db_map, self._entity_group_fetch_parent) - @property - def object_name_list(self): - return self.db_map_data_field(self.first_db_map, "object_name_list", default="") + def fetch_more(self): + self._fetch_more_entity_groups() + super().fetch_more() - @property - def display_data(self): - """ "Returns the name for display.""" - return DB_ITEM_SEPARATOR.join( - [x for x in self.object_name_list if x != self.parent_item.parent_item.display_data] - ) + def _key_for_index(self, db_map): + return self.db_map_id(db_map) - @property - def edit_data(self): - return DB_ITEM_SEPARATOR.join(self.object_name_list) + def _key_for_entity_group_index(self, db_map): + return self.db_map_id(db_map) - def default_parameter_data(self): - """Return data to put as default in a parameter table when this item is selected.""" - return dict( - relationship_class_name=self.parent_item.display_data, - object_name_list=DB_ITEM_SEPARATOR.join(self.object_name_list), - database=self.first_db_map.codename, - ) + def accepts_item(self, item, db_map): + return self.db_map_id(db_map) in item["element_id_list"] - def has_children(self): - return False + def _accepts_entity_group_item(self, item, db_map): + return item["group_id"] == self.db_map_id(db_map) - def can_fetch_more(self): - return False + def _handle_entity_group_items_added(self, db_map_data): + db_map_member_ids = {db_map: [x["member_id"] for x in data] for db_map, data in db_map_data.items()} + self.append_children_by_id(db_map_member_ids, is_member=True) + if not self._is_group: + self._is_group = True + self.parent_item.reposition_child(self.child_number()) + + def _handle_entity_group_items_removed(self, db_map_data): + db_map_ids = {db_map: [x["member_id"] for x in data] for db_map, data in db_map_data.items()} + self.remove_children_by_id(db_map_ids) + if not any(self.db_mngr.get_item(db_map, "entity", self.db_map_id(db_map)) for db_map in self.db_maps): + # Not an entity anymore + return + if self._is_group: + if any( + self.db_mngr.get_items_by_field(db_map, "entity_group", "group_id", self.db_map_id(db_map)) + for db_map in self.db_maps + ): + # Still a group + return + self._is_group = False + self.parent_item.reposition_child(self.child_number()) - def is_valid(self): - """Checks that the grand parent object is still in the relationship.""" - grand_parent = self.parent_item.parent_item - if grand_parent.item_type == "root": - return True - return grand_parent.display_data in self.object_name_list + def tear_down(self): + super().tear_down() + self._entity_group_fetch_parent.set_obsolete(True) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_models.py b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_models.py index 4fc28b1d4..77736d944 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/entity_tree_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,56 +10,85 @@ # this program. If not, see . ###################################################################################################################### -""" -Models to represent entities in a tree. -""" - -from .entity_tree_item import ObjectTreeRootItem, RelationshipTreeRootItem +"""Models to represent entities in a tree.""" +from .entity_tree_item import EntityTreeRootItem +from .multi_db_tree_item import MultiDBTreeItem from .multi_db_tree_model import MultiDBTreeModel -class ObjectTreeModel(MultiDBTreeModel): - """An 'object-oriented' tree model.""" +class EntityTreeModel(MultiDBTreeModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._hide_empty_classes = ( + self.db_editor.qsettings.value("appSettings/hideEmptyClasses", defaultValue="false") == "true" + ) @property def root_item_type(self): - return ObjectTreeRootItem + return EntityTreeRootItem - def find_next_relationship_index(self, index): + def find_next_entity_index(self, index): """Find and return next occurrence of relationship item.""" if not index.isValid(): return None - rel_item = self.item_from_index(index) - if not rel_item.item_type == "relationship": + ent_item = self.item_from_index(index) + if not (ent_item.item_type == "entity" and ent_item.element_name_list): return None # Get all ancestors - rel_cls_item = rel_item.parent_item - obj_item = rel_cls_item.parent_item - for db_map in rel_item.db_maps: + el_item = ent_item.parent_item + if el_item.item_type != "entity": + return + for db_map in ent_item.db_maps: # Get data from ancestors - rel_data = rel_item.db_map_data(db_map) - rel_cls_data = rel_cls_item.db_map_data(db_map) - obj_data = obj_item.db_map_data(db_map) + ent_data = ent_item.db_map_data(db_map) + el_data = el_item.db_map_data(db_map) # Get specific data for our searches - rel_cls_id = rel_cls_data['id'] - obj_id = obj_data['id'] - object_ids = list(reversed(rel_data['object_id_list'])) - object_class_ids = list(reversed(rel_cls_data['object_class_id_list'])) - # Find position in the relationship of the (grand parent) object, - # then use it to determine object_class and object id to look for - pos = object_ids.index(obj_id) - 1 - object_id = object_ids[pos] - object_class_id = object_class_ids[pos] + el_id = el_data["id"] + element_ids = list(reversed(ent_data["element_id_list"])) + dimension_ids = list(reversed(ent_data["dimension_id_list"])) + # Find position in the entity of the (grand parent) element, + # then use it to determine dimension and element id to look for + pos = element_ids.index(el_id) - 1 + element_id = element_ids[pos] + dimension_id = dimension_ids[pos] # Return first node that passes all cascade filters - for parent_item in self.find_items(db_map, (object_class_id, object_id, rel_cls_id), fetch=True): - for item in parent_item.find_children(lambda child: child.display_id == rel_item.display_id): + for parent_item in self.find_items(db_map, (dimension_id, element_id), fetch=True): + for item in parent_item.find_children(lambda child: child.display_id == ent_item.display_id): return self.index_from_item(item) - return None - -class RelationshipTreeModel(MultiDBTreeModel): - """A relationship-oriented tree model.""" + def save_hide_empty_classes(self): + hide_empty_classes = "true" if self.hide_empty_classes else "false" + self.db_editor.qsettings.setValue("appSettings/hideEmptyClasses", hide_empty_classes) @property - def root_item_type(self): - return RelationshipTreeRootItem + def hide_empty_classes(self): + return self._hide_empty_classes + + @hide_empty_classes.setter + def hide_empty_classes(self, hide_empty_classes): + if self._hide_empty_classes is hide_empty_classes: + return + self._hide_empty_classes = hide_empty_classes + self.root_item.refresh_child_map() + + +def group_items_by_db_map(indexes): + """Groups items from given tree indexes by db map. + + Args: + indexes (Iterable of QModelIndex): index to entity tree model + + Returns: + dict: lists of dictionary items keyed by DatabaseMapping + """ + d = {} + for index in indexes: + model = index.model() + if model is None: + continue + item = model.item_from_index(index) + if item.item_type == "root": + continue + for db_map in item.db_maps: + d.setdefault(db_map, []).append(item.db_map_data(db_map)) + return d diff --git a/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py b/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py index 432a5cf9f..0e6fa64a1 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/frozen_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains FrozenTableModel class. -""" +"""Contains FrozenTableModel class.""" from itertools import product - from PySide6.QtCore import Qt, QModelIndex, QAbstractTableModel, Signal from .colors import SELECTED_COLOR -from ...helpers import rows_to_row_count_tuples +from ...helpers import plain_to_tool_tip, rows_to_row_count_tuples class FrozenTableModel(QAbstractTableModel): @@ -42,13 +40,18 @@ def set_headers(self, headers): Args: headers (Iterable of str): headers + + Returns: + bool: True if model was reset, False otherwise """ headers = list(headers) if self._data and headers == self._data[0]: - return + return False self.beginResetModel() self._data = [headers] + self._selected_row = None self.endResetModel() + return True def clear_model(self): self.beginResetModel() @@ -70,7 +73,8 @@ def add_values(self, data): self.beginInsertRows(QModelIndex(), old_size, old_size + len(new_values) - 1) self._data += new_values self.endInsertRows() - self._keep_sorted() + had_data_before = bool(unique_data) + self._keep_sorted(update_selected_row=had_data_before) def remove_values(self, data): """Removes frozen values from the table. @@ -78,29 +82,25 @@ def remove_values(self, data): Args: data (set of tuple): frozen values """ - removed_i = set() - for removed_row in data: - for i, row in enumerate(self._data[1:]): - if row == removed_row: - removed_i.add(i + 1) - break - if not removed_i: + removed_rows = {i + 1 for i, val in enumerate(self._data[1:]) if val in data} + if not removed_rows: return - frozen_value = self._data[self._selected_row] - intervals = rows_to_row_count_tuples(removed_i) - for interval in reversed(intervals): - end = interval[0] + interval[1] - self.beginRemoveRows(QModelIndex(), interval[0], end - 1) - del self._data[interval[0] : end] - self.endRemoveRows() - if self._selected_row in removed_i: - self._selected_row = min(self._selected_row, len(self._data) - 1) - self.selected_row_changed.emit() + if self._selected_row is not None and self._selected_row not in removed_rows: + frozen_value = self._data[self._selected_row] else: + frozen_value = None + for first, count in reversed(rows_to_row_count_tuples(removed_rows)): + last = first + count - 1 + self.beginRemoveRows(QModelIndex(), first, last) + del self._data[first : last + 1] + self.endRemoveRows() + if frozen_value is not None: selected_row = self._find_first(frozen_value) - if selected_row != self._selected_row: - self._selected_row = selected_row - self.selected_row_changed.emit() + else: + selected_row = 1 if len(self._data) > 1 else None + if selected_row != self._selected_row: + self._selected_row = selected_row + self.selected_row_changed.emit() def clear_selected(self): """Clears selected row.""" @@ -128,6 +128,14 @@ def set_selected(self, row): self.dataChanged.emit(new_bottom_right, new_top_left, [Qt.ItemDataRole.BackgroundRole]) self.selected_row_changed.emit() + def get_selected(self): + """Returns selected row. + + Returns: + int: row index or None if no row is selected + """ + return self._selected_row + def get_frozen_value(self): """Return currently selected frozen value. @@ -169,12 +177,12 @@ def insert_column_data(self, header, values, column): self.endInsertColumns() return column_values = self._unique_values() - new_data = [row for row in product(*column_values[:column], values, *column_values[column:])] - previous_selected_value = self._data[self._selected_row] if self._selected_row is not None else None + new_data = list(product(*column_values[:column], values, *column_values[column:])) + previously_selected_value = self._data[self._selected_row] if self._selected_row is not None else None self.beginResetModel() self._data[0] = headers[:column] + [header] + headers[column:] self._data[1:] = new_data - self._selected_row = self._find_first(previous_selected_value, column) + self._selected_row = self._find_first(previously_selected_value, column) self.endResetModel() self._keep_sorted() @@ -196,7 +204,7 @@ def remove_column(self, column): self.endRemoveColumns() return column_values = self._unique_values() - new_data = [row for row in product(*column_values[:column], *column_values[column + 1 :])] + new_data = list(product(*column_values[:column], *column_values[column + 1 :])) selected_data = self._data[self._selected_row] self.beginResetModel() self._data[0] = headers[:column] + headers[column + 1 :] @@ -220,7 +228,7 @@ def moveColumns(self, sourceParent, sourceColumn, count, destinationParent, dest self._keep_sorted() return True - def _keep_sorted(self): + def _keep_sorted(self, update_selected_row=True): """Sorts the data table.""" if len(self._data) < 3: return @@ -235,11 +243,21 @@ def _keep_sorted(self): key=lambda x: tuple(self._name_from_data(x[column], header[column]) for column in range(column_count)), ) self._data[1:] = data + selected_row_changed = False if frozen_value is not None: - self._selected_row = self._find_first(frozen_value) + if update_selected_row: + candidate = self._find_first(frozen_value) + if self._selected_row != candidate: + self._selected_row = candidate + selected_row_changed = True + elif frozen_value != self.get_frozen_value(): + # The row did not change but the frozen value did. + selected_row_changed = True self.layoutChanged["QList", "QAbstractItemModel::LayoutChangeHint"].emit( [], QAbstractTableModel.LayoutChangeHint.VerticalSortHint ) + if selected_row_changed: + self.selected_row_changed.emit() def _unique_values(self): """Turns non-header data into sets of unique values on each column. @@ -309,22 +327,26 @@ def _tooltip_from_data(self, row, column): header = self._data[0][column] if header == "parameter": db_map, id_ = value - return self.db_mngr.get_item(db_map, "parameter_definition", id_)["description"] - if header == "alternative": + tool_tip = self.db_mngr.get_item(db_map, "parameter_definition", id_).get("description") + elif header == "alternative": db_map, id_ = value - return self.db_mngr.get_item(db_map, "alternative", id_)["description"] - if header == "index": - return str(value[1]) - if header == "database": - return value.codename - db_map, id_ = value - return self.db_mngr.get_item(db_map, "object", id_)["description"] + tool_tip = self.db_mngr.get_item(db_map, "alternative", id_).get("description") + elif header == "index": + tool_tip = str(value[1]) + elif header == "database": + tool_tip = value.codename + elif header == "entity": + db_map, id_ = value + tool_tip = self.db_mngr.get_item(db_map, "entity", id_).get("description") + else: + raise RuntimeError(f"Logic error: unknown header '{header}'") + return plain_to_tool_tip(tool_tip) def _name_from_data(self, value, header): """Resolves item name. Args: - value (tuple or DatabaseMappingBase): cell value + value (tuple or DatabaseMapping): cell value header (str): column header Returns: @@ -333,7 +355,7 @@ def _name_from_data(self, value, header): if header == "parameter": db_map, id_ = value item = self.db_mngr.get_item(db_map, "parameter_definition", id_) - return item.get("parameter_name") + return item.get("name") if header == "alternative": db_map, id_ = value item = self.db_mngr.get_item(db_map, "alternative", id_) @@ -343,7 +365,7 @@ def _name_from_data(self, value, header): if header == "database": return value.codename db_map, id_ = value - item = self.db_mngr.get_item(db_map, "object", id_) + item = self.db_mngr.get_item(db_map, "entity", id_) return item.get("name") @property diff --git a/spinetoolbox/spine_db_editor/mvcmodels/item_metadata_table_model.py b/spinetoolbox/spine_db_editor/mvcmodels/item_metadata_table_model.py index 6e1f65ab8..e92300d58 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/item_metadata_table_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/item_metadata_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains :class:`ItemMetadataTableModel` and associated functionality. -""" +"""Contains :class:`ItemMetadataTableModel` and associated functionality.""" from enum import auto, Enum, IntEnum, unique - -from PySide6.QtCore import QModelIndex - -from spinetoolbox.helpers import rows_to_row_count_tuples from spinetoolbox.fetch_parent import FlexibleFetchParent from .metadata_table_model_base import Column, FLAGS_EDITABLE, FLAGS_FIXED, MetadataTableModelBase @@ -47,7 +42,7 @@ def __init__(self, db_mngr, db_maps, db_editor): """ Args: db_mngr (SpineDBManager): database manager - db_maps (Iterable of DatabaseMappingBase): database maps + db_maps (Iterable of DatabaseMapping): database maps db_editor (SpineDBEditor): DB editor """ super().__init__(db_mngr, db_maps, db_editor) @@ -125,7 +120,7 @@ def _reset_metadata(self, item_type, db_map_ids): self.beginResetModel() self._item_type = item_type self._item_ids = dict(db_map_ids) - self._db_maps = set(db_map_ids.keys()) + self._db_maps = set(db_map_ids) default_db_map = next(iter(self._db_maps)) if self._db_maps else None self._adder_row = self._make_adder_row(default_db_map) self._data = [] @@ -133,7 +128,7 @@ def _reset_metadata(self, item_type, db_map_ids): def _reset_fetch_parents(self): for parent in self._fetch_parents(): - parent.reset_fetching(None) + parent.reset() if self.canFetchMore(None): self.fetchMore(None) @@ -141,36 +136,25 @@ def _add_data_to_db_mngr(self, name, value, db_map): """See base class.""" item_id = self._item_ids[db_map] if self._item_type == ItemType.ENTITY: - self._db_mngr.add_entity_metadata( + self._db_mngr.add_ext_entity_metadata( {db_map: [{"entity_id": item_id, "metadata_name": name, "metadata_value": value}]} ) else: - self._db_mngr.add_parameter_value_metadata( + self._db_mngr.add_ext_parameter_value_metadata( {db_map: [{"parameter_value_id": item_id, "metadata_name": name, "metadata_value": value}]} ) def _update_data_in_db_mngr(self, id_, name, value, db_map): """See base class""" if self._item_type == ItemType.ENTITY: - self._db_mngr.update_entity_metadata( + self._db_mngr.update_ext_entity_metadata( {db_map: [{"id": id_, "metadata_name": name, "metadata_value": value}]} ) else: - self._db_mngr.update_parameter_value_metadata( + self._db_mngr.update_ext_parameter_value_metadata( {db_map: [{"id": id_, "metadata_name": name, "metadata_value": value}]} ) - def rollback(self, _db_maps): - """Rolls back changes in database. - - Args: - db_maps (Iterable of DiffDatabaseMapping): database mappings that have been rolled back - """ - self.beginResetModel() - self._data = [] - self.endResetModel() - self._reset_fetch_parents() - def flags(self, index): row = index.row() column = index.column() @@ -217,13 +201,7 @@ def update_item_metadata(self, db_map_data): Args: db_map_data (dict): updated metadata records """ - for db_map, items in db_map_data.items(): - for item in items: - for row in self._data: - if db_map != row[Column.DB_MAP] or item["id"] != row[ExtraColumn.ITEM_METADATA_ID]: - continue - row[ExtraColumn.METADATA_ID] = item["metadata_id"] - break + self._update_data(db_map_data, ExtraColumn.ITEM_METADATA_ID) def remove_item_metadata(self, db_map_data): """Removes item metadata from model after it has been removed from databases. diff --git a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model.py b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model.py index 1ab36cb45..c58ff1dff 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains :class:`MetadataTableModel` and associated functionality. -""" +"""Contains :class:`MetadataTableModel` and associated functionality.""" from enum import IntEnum, unique -from PySide6.QtCore import QModelIndex, Qt -from spinetoolbox.helpers import rows_to_row_count_tuples from spinetoolbox.fetch_parent import FlexibleFetchParent from .metadata_table_model_base import Column, FLAGS_FIXED, FLAGS_EDITABLE, MetadataTableModelBase @@ -36,7 +33,7 @@ def __init__(self, db_mngr, db_maps, db_editor): """ Args: db_mngr (SpineDBManager): database manager - db_maps (Iterable of DatabaseMappingBase): database maps + db_maps (Iterable of DatabaseMapping): database maps db_editor (SpineDBEditor): DB editor """ super().__init__(db_mngr, db_maps, db_editor) @@ -61,24 +58,6 @@ def _update_data_in_db_mngr(self, id_, name, value, db_map): """See base class""" self._db_mngr.update_metadata({db_map: [{"id": id_, "name": name, "value": value}]}) - def rollback(self, db_maps): - """Rolls back changes in database. - - Args: - db_maps (Iterable of DiffDatabaseMapping): database mappings that have been rolled back - """ - spans = rows_to_row_count_tuples( - i for db_map in db_maps for i, row in enumerate(self._data) if row[Column.DB_MAP] == db_map - ) - for span in spans: - first = span[0] - last = span[0] + span[1] - 1 - self.beginRemoveRows(QModelIndex(), first, last) - self._data = self._data[:first] + self._data[last + 1 :] - self.endRemoveRows() - if self.canFetchMore(QModelIndex()): - self.fetchMore(QModelIndex()) - def _database_table_name(self): """See base class""" return "metadata" @@ -125,25 +104,7 @@ def update_metadata(self, db_map_data): Args: db_map_data (dict): updated metadata items keyed by database mapping """ - for items in db_map_data.values(): - items_by_id = {item["id"]: item for item in items} - updated_rows = [] - for row_index, row in enumerate(self._data): - if row[ExtraColumn.ID] is None: - continue - db_item = items_by_id.get(row[ExtraColumn.ID]) - if db_item is None: - continue - if row[Column.NAME] != db_item["name"]: - row[Column.NAME] = db_item["name"] - updated_rows.append(row_index) - if row[Column.VALUE] != db_item["value"]: - row[Column.VALUE] = db_item["value"] - updated_rows.append(row_index) - if updated_rows: - top_left = self.index(updated_rows[0], 0) - bottom_right = self.index(updated_rows[-1], Column.DB_MAP - 1) - self.dataChanged.emit(top_left, bottom_right, [Qt.ItemDataRole.DisplayRole]) + self._update_data(db_map_data, ExtraColumn.ID) def remove_metadata(self, db_map_data): """Removes metadata from model after it has been removed from databases. diff --git a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py index 7bb99534f..9ed04cc88 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/metadata_table_model_base.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains base class for metadata table models associated functionality. -""" +"""Contains base class for metadata table models associated functionality.""" from enum import IntEnum, unique from operator import itemgetter - from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal from spinetoolbox.helpers import rows_to_row_count_tuples from .colors import FIXED_FIELD_COLOR @@ -51,7 +49,7 @@ def __init__(self, db_mngr, db_maps, db_editor): """ Args: db_mngr (SpineDBManager): database manager - db_maps (Iterable of DatabaseMappingBase): database maps + db_maps (Iterable of DatabaseMapping): database maps db_editor (SpineDBEditor): DB editor """ super().__init__(db_editor) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/mime_types.py b/spinetoolbox/spine_db_editor/mvcmodels/mime_types.py index 4ce28e831..3de2ac578 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/mime_types.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/mime_types.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,6 +9,7 @@ # 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 . ###################################################################################################################### + """Contains mime types used by the models.""" ALTERNATIVE_DATA = "application/vnd.spinetoolbox.alternative" diff --git a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py index bd73a7cd0..c35f85b08 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_item.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,11 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Base classes to represent items from multiple databases in a tree. -""" +"""Base classes to represent items from multiple databases in a tree.""" from operator import attrgetter - from PySide6.QtCore import Qt from ...helpers import rows_to_row_count_tuples, bisect_chunks from ...fetch_parent import FlexibleFetchParent @@ -26,38 +24,65 @@ class MultiDBTreeItem(TreeItem): item_type = None """Item type identifier string. Should be set to a meaningful value by subclasses.""" visual_key = ["name"] + _fetch_index = None - def __init__(self, model=None, db_map_ids=None): - """Init class. - + def __init__(self, model, db_map_ids=None): + """ Args: model (MinimalTreeModel, optional): item's model - db_map_ids (dict, optional): maps instances of DiffDatabaseMapping to the id of the item in that db + db_map_ids (dict, optional): maps instances of DatabaseMapping to the id of the item in that db """ super().__init__(model) if db_map_ids is None: db_map_ids = {} self._db_map_ids = db_map_ids - self._child_map = dict() # Maps db_map to id to row number + self._child_map = {} # Maps db_map to id to row number self._fetch_parent = FlexibleFetchParent( self.fetch_item_type, accepts_item=self.accepts_item, handle_items_added=self.handle_items_added, handle_items_removed=self.handle_items_removed, handle_items_updated=self.handle_items_updated, - will_have_children_change=self.will_have_children_change, + index=self._fetch_index, + key_for_index=self._key_for_index, owner=self, ) + @property + def visible_children(self): + return self.children + + def row_count(self): + """Overriden to use visible_children.""" + return len(self.visible_children) + + def child(self, row): + """Overriden to use visible_children.""" + if 0 <= row < self.row_count(): + return self.visible_children[row] + return None + def child_number(self): + """Overriden to use find_row which is a dict-lookup rather than a list.index() call.""" + if not self.parent_item: + return None try: db_map, id_ = next(iter(self._db_map_ids.items())) except StopIteration: - return -1 - try: + return None + if isinstance(self.parent_item, MultiDBTreeItem): return self.parent_item.find_row(db_map, id_) - except AttributeError: - return super().child_number() + return 0 + + def refresh_child_map(self): + """Recomputes the child map.""" + self.model.layoutAboutToBeChanged.emit() + self._child_map.clear() + for row, child in enumerate(self.visible_children): + for db_map in child.db_maps: + id_ = child.db_map_id(db_map) + self._child_map.setdefault(db_map, {})[id_] = row + self.model.layoutChanged.emit() def set_data(self, column, value, role): raise NotImplementedError() @@ -68,8 +93,8 @@ def db_mngr(self): @property def child_item_class(self): - """Returns the type of child items. Reimplement in subclasses to return something more meaningful.""" - return MultiDBTreeItem + """Returns the type of child items.""" + raise NotImplementedError() @property def display_id(self): @@ -81,10 +106,14 @@ def display_id(self): return None return next(iter(ids)) + @property + def name(self): + return self.db_map_data_field(self.first_db_map, "name", default="") + @property def display_data(self): """Returns the name for display.""" - return self.db_map_data_field(self.first_db_map, "name") + return self.name @property def display_database(self): @@ -100,17 +129,12 @@ def display_icon(self): @property def first_db_map(self): """Returns the first associated db_map.""" - return list(self._db_map_ids.keys())[0] - - @property - def last_db_map(self): - """Returns the last associated db_map.""" - return list(self._db_map_ids.keys())[-1] + return next(iter(self._db_map_ids)) @property def db_maps(self): """Returns a list of all associated db_maps.""" - return list(self._db_map_ids.keys()) + return list(self._db_map_ids) @property def db_map_ids(self): @@ -128,20 +152,20 @@ def take_db_map(self, db_map): """Removes the mapping for given db_map and returns it.""" return self._db_map_ids.pop(db_map, None) - def _deep_refresh_children(self): + def deep_refresh_children(self): """Refreshes children after taking db_maps from them. Called after removing and updating children for this item.""" removed_rows = [] for row, child in reversed(list(enumerate(self.children))): - if not child._db_map_ids: + if not child.db_map_ids: removed_rows.append(row) for row, count in reversed(rows_to_row_count_tuples(removed_rows)): self.remove_children(row, count) for row, child in enumerate(self.children): - child._deep_refresh_children() + child.deep_refresh_children() if self.children: top_row = 0 - bottom_row = self.child_count() - 1 + bottom_row = self.row_count() - 1 top_index = self.children[top_row].index().sibling(top_row, 1) bottom_index = self.children[bottom_row].index().sibling(bottom_row, 1) self.model.dataChanged.emit(top_index, bottom_index) @@ -162,7 +186,7 @@ def deep_take_db_map(self, db_map): id_ = self.take_db_map(db_map) if id_ is None: return None - other = type(self)(model=self.model, db_map_ids={db_map: id_}) + other = self.parent_item.make_or_restore_child({db_map: id_}) other_children = [] for child in self.children: other_child = child.deep_take_db_map(db_map) @@ -174,7 +198,7 @@ def deep_take_db_map(self, db_map): def deep_merge(self, other): """Merges another item and all its descendants into this one.""" if not isinstance(other, type(self)): - raise ValueError(f"Can't merge an instance of {type(other)} into a MultiDBTreeItem.") + raise ValueError(f"Can't merge an instance of {type(other).__name__} into a MultiDBTreeItem.") for db_map in other.db_maps: self.add_db_map_id(db_map, other.db_map_id(db_map)) self._merge_children(other.children) @@ -184,7 +208,7 @@ def db_map_id(self, db_map): return self._db_map_ids.get(db_map) def db_map_data(self, db_map): - """Returns data for this item in given db_map or None if not present.""" + """Returns data for this item in given db_map or an empty dict if not present.""" id_ = self.db_map_id(db_map) return self.db_mngr.get_item(db_map, self.item_type, id_) @@ -203,12 +227,43 @@ def _create_new_children(self, db_map, children_ids, **kwargs): Returns: list of MultiDBTreeItem: new children """ - return [self.child_item_class(self.model, {db_map: id_}, **kwargs) for id_ in children_ids] + return [self.make_or_restore_child(db_map, id_, **kwargs) for id_ in children_ids] + + def make_or_restore_child(self, db_map, id_, **kwargs): + """Makes or restores a child if one was ever made using given db_map and id. + The purpose of restoring is to keep using the same FetchParent, + which is useful in case the user undoes a series of removal operations + that would add items in cascade to the tree. + + Args: + db_map (DatabaseMapping) + id (int) + + Returns: + MultiDBTreemItem + """ + db_map_ids = {db_map: id_} + key = (db_map, id_) + child = self._created_children.get(key) + if child is not None: + child.restore(db_map_ids, **kwargs) + return child + child = self._created_children[key] = self._make_child(db_map_ids, **kwargs) + return child + + def restore(self, db_map_ids, **kwargs): + self._db_map_ids.update(db_map_ids) + + def _make_child(self, db_map_ids, **kwargs): + return self.child_item_class(self.model, db_map_ids, **kwargs) def _merge_children(self, new_children): """Merges new children into this item. Ensures that each child has a valid display id afterwards.""" if not new_children: return + if len(self._db_map_ids) == 1: + self._insert_children_sorted(new_children) + return existing_children = {child.display_id: child for child in self.children} unmerged = [] for new_child in new_children: @@ -222,13 +277,13 @@ def _merge_children(self, new_children): existing_children[new_child.display_id] = new_child unmerged.append(new_child) if not unmerged: - self._refresh_child_map() + self.refresh_child_map() return self._insert_children_sorted(unmerged) def _insert_children_sorted(self, new_children): """Inserts and sorts children.""" - new_children = sorted(new_children, key=attrgetter("display_id")) + new_children = sorted(new_children, key=self._children_sort_key) for chunk, pos in bisect_chunks(self.children, new_children, key=self._children_sort_key): self.insert_children(pos, chunk) @@ -236,11 +291,6 @@ def _insert_children_sorted(self, new_children): def _children_sort_key(self): return attrgetter("display_id") - def will_have_children_change(self): - """Notifies the view that the model's layout has changed. - This triggers a repaint so this item will be painted gray if no children.""" - self.model.layoutChanged.emit() - @property def fetch_item_type(self): return self.child_item_class.item_type @@ -264,6 +314,9 @@ def fetch_more_if_possible(self): if self.can_fetch_more(): self.fetch_more() + def _key_for_index(self, db_map): + return None + def accepts_item(self, item, db_map): return True @@ -301,11 +354,11 @@ def remove_children_by_id(self, db_map_ids): for db_map, ids in db_map_ids.items(): for child in self.find_children_by_id(db_map, *ids, reverse=True): child.deep_remove_db_map(db_map) - self._deep_refresh_children() + self.deep_refresh_children() - def is_valid(self): # pylint: disable=no-self-use - """Checks if the item is still valid after an update operation.""" - return True + def is_valid(self): + """See base class.""" + return bool(self._db_map_ids) def update_children_by_id(self, db_map_ids, **kwargs): """ @@ -328,7 +381,7 @@ def update_children_by_id(self, db_map_ids, **kwargs): for db_map, ids in db_map_ids.items(): for id_ in ids: row = self.find_row(db_map, id_) - if row != -1: + if row is not None: rows_to_update.add(row) else: db_map_ids_to_add.setdefault(db_map, set()).add(id_) @@ -339,7 +392,6 @@ def update_children_by_id(self, db_map_ids, **kwargs): display_ids = [child.display_id for child in self.children if child.display_id is not None] for row in sorted(rows_to_update, reverse=True): child = self.child(row) - child.update(**kwargs) if not child: continue if not child.is_valid(): @@ -351,61 +403,54 @@ def update_children_by_id(self, db_map_ids, **kwargs): db_map = child.first_db_map new_child = child.deep_take_db_map(db_map) new_children.append(new_child) - if child.display_id in display_ids[:row] + display_ids[row + 1 :] or child.should_be_merged(): + if child.display_id in display_ids[:row] + display_ids[row + 1 :]: # Take the child and put it in the list to be merged + new_children.append(child) self.remove_children(row, 1) display_ids.pop(row) - child.revitalize() new_children.append(child) - self._deep_refresh_children() + self.deep_refresh_children() self._merge_children(new_children) top_left = self.model.index(0, 0, self.index()) - bottom_right = self.model.index(self.child_count() - 1, 0, self.index()) + bottom_right = self.model.index(self.row_count() - 1, 0, self.index()) self.model.dataChanged.emit(top_left, bottom_right) - def update(self, **kwargs): - pass - - def should_be_merged(self): - return False - def insert_children(self, position, children): - """Insert new children at given position. Returns a boolean depending on how it went. + """Inserts new children at given position. Args: position (int): insert new items here - children (iter): insert items from this iterable + children (Iterable of MultiDBTreeItem): insert items from this iterable + + Returns: + bool: True if children were inserted successfully, False otherwise """ bad_types = [type(child) for child in children if not isinstance(child, MultiDBTreeItem)] if bad_types: - raise TypeError(f"Cand't insert children of type {bad_types} to an item of type {type(self)}") + raise TypeError(f"Can't insert children of type {bad_types} to an item of type {type(self)}") if not super().insert_children(position, children): return False - self._refresh_child_map() + self.refresh_child_map() + for child in children: + child.register_fetch_parent() return True def remove_children(self, position, count): """Removes count children starting from the given position.""" if super().remove_children(position, count): - self._refresh_child_map() + self.refresh_child_map() return True return False - def clear_children(self): - """Clear children list.""" - super().clear_children() - self._child_map.clear() - - def _refresh_child_map(self): - """Recomputes the child map.""" - self._child_map.clear() - for row, child in enumerate(self.children): - for db_map in child.db_maps: - id_ = child.db_map_id(db_map) - self._child_map.setdefault(db_map, dict())[id_] = row + def reposition_child(self, row): + child = self.child(row) + if not child: + return + self.remove_children(row, 1) + self._insert_children_sorted([child]) def find_row(self, db_map, id_): - return self._child_map.get(db_map, {}).get(id_, -1) + return self._child_map.get(db_map, {}).get(id_) def find_children_by_id(self, db_map, *ids, reverse=True): """Generates children with the given ids in the given db_map. @@ -427,20 +472,21 @@ def _find_unsorted_rows_by_id(self, db_map, *ids): # Yield all children with the db_map *and* the id for id_ in ids: row = self.find_row(db_map, id_) - if row != -1: + if row is not None: yield row def data(self, column, role=Qt.ItemDataRole.DisplayRole): """Returns data for given column and role.""" - if column == 0: - if role == Qt.ItemDataRole.DecorationRole: - return self.display_icon - if role == Qt.ItemDataRole.DisplayRole: + if role == Qt.ItemDataRole.DisplayRole: + if column == 0: return self.display_data - if role == Qt.ItemDataRole.EditRole: - return self.edit_data - if column and role == Qt.ItemDataRole.DisplayRole: - return self.display_database + if column == 1: + return self.display_database + if role == Qt.ItemDataRole.DecorationRole: + if column == 0: + return self.display_icon + if role == Qt.ItemDataRole.EditRole: + return self.edit_data def default_parameter_data(self): """Returns data to set as default in a parameter table when this item is selected.""" @@ -450,8 +496,7 @@ def tear_down(self): super().tear_down() self._fetch_parent.set_obsolete(True) - def revitalize(self): - """Reverts tear down operation""" - self._fetch_parent.set_obsolete(False) - for child in self._children: - child.revitalize() + def register_fetch_parent(self): + """Registers item's fetch parent for all model's databases.""" + for db_map in self.model.db_maps: + self.model.db_mngr.register_fetch_parent(db_map, self._fetch_parent) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_model.py b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_model.py index 04109a25d..7a9650524 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/multi_db_tree_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A base model class to represent items from multiple databases in a tree. -""" +"""A base model class to represent items from multiple databases in a tree.""" from PySide6.QtCore import QModelIndex, Qt from ...mvcmodels.minimal_tree_model import MinimalTreeModel, TreeItem @@ -25,12 +24,14 @@ def __init__(self, db_editor, db_mngr, *db_maps): Args: db_editor (SpineDBEditor) db_mngr (SpineDBManager): A manager for the given db_maps - db_maps (iter): DiffDatabaseMapping instances + *db_maps: DatabaseMapping instances """ super().__init__(db_editor) self.db_editor = db_editor self.db_mngr = db_mngr self.db_maps = db_maps + self._invisible_root_item = TreeItem(self) + self.destroyed.connect(lambda obj=None: self._invisible_root_item.tear_down_recursively()) self._root_item = None @property @@ -46,20 +47,26 @@ def root_item(self): def root_index(self): return self.index_from_item(self._root_item) + @property + def _header_labels(self): + return ("name", "database") + def build_tree(self): """Builds tree.""" - self.beginResetModel() - self._invisible_root_item = TreeItem(self) + if self._invisible_root_item.has_children(): + self.beginRemoveRows(QModelIndex(), 0, self.rowCount() - 1) + self._invisible_root_item = TreeItem(self) + self.destroyed.connect(lambda obj=None: self._invisible_root_item.tear_down_recursively()) + self.endRemoveRows() self._root_item = self.root_item_type(self, dict.fromkeys(self.db_maps)) self._invisible_root_item.append_children([self._root_item]) - self.endResetModel() def columnCount(self, parent=QModelIndex()): - return 2 + return len(self._header_labels) def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: - return ("name", "database")[section] + return self._header_labels[section] return None def find_items(self, db_map, path_prefix, fetch=False): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_mixins.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_mixins.py deleted file mode 100644 index c1a8cef35..000000000 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_mixins.py +++ /dev/null @@ -1,564 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Miscelaneous mixins for parameter models -""" - -from spinedb_api.parameter_value import split_value_and_type - - -class ConvertToDBMixin: - """Base class for all mixins that convert model items (name-based) into database items (id-based).""" - - def build_lookup_dictionary(self, db_map_data): - """Begins an operation to convert items.""" - - # pylint: disable=no-self-use - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item = item.copy() - value_field, type_field = { - "parameter_value": ("value", "type"), - "parameter_definition": ("default_value", "default_type"), - }[self.item_type] - if value_field in item: - value, value_type = split_value_and_type(item[value_field]) - item[value_field] = value - item[type_field] = value_type - return item, [] - - -class FillInAlternativeIdMixin(ConvertToDBMixin): - """Fills in alternative names.""" - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_alt_lookup = dict() - - def build_lookup_dictionary(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping - """ - super().build_lookup_dictionary(db_map_data) - # Group data by name - db_map_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - name = item.get("alternative_name") - db_map_names.setdefault(db_map, set()).add(name) - # Build lookup dict - self._db_map_alt_lookup.clear() - for db_map, names in db_map_names.items(): - for name in names: - item = self.db_mngr.get_item_by_field(db_map, "alternative", "name", name, only_visible=False) - if item: - self._db_map_alt_lookup.setdefault(db_map, {})[name] = item - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err = super()._convert_to_db(item, db_map) - alt_name = item.pop("alternative_name", None) - alt = self._db_map_alt_lookup.get(db_map, {}).get(alt_name) - if not alt: - return item, err + [f"Unknown alternative name {alt_name}"] if alt_name else err - item["alternative_id"] = alt["id"] - return item, err - - -class FillInParameterNameMixin(ConvertToDBMixin): - """Fills in parameter names.""" - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err = super()._convert_to_db(item, db_map) - name = item.pop("parameter_name", None) - if name: - item["name"] = name - return item, err - - -class FillInValueListIdMixin(ConvertToDBMixin): - """Fills in value list ids.""" - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_value_list_lookup = dict() - - def build_lookup_dictionary(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping - """ - super().build_lookup_dictionary(db_map_data) - # Group data by name - db_map_value_list_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - value_list_name = item.get("value_list_name") - db_map_value_list_names.setdefault(db_map, set()).add(value_list_name) - # Build lookup dict - self._db_map_value_list_lookup.clear() - for db_map, names in db_map_value_list_names.items(): - for name in names: - item = self.db_mngr.get_item_by_field(db_map, "parameter_value_list", "name", name, only_visible=False) - if item: - self._db_map_value_list_lookup.setdefault(db_map, {})[name] = item - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._fill_in_value_list_id(item, db_map) - return item, err1 + err2 - - def _fill_in_value_list_id(self, item, db_map): - """Fills in the value list id in the given db item. - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - if "value_list_name" not in item: - return [] - value_list_name = item.pop("value_list_name") - if value_list_name: - value_list = self._db_map_value_list_lookup.get(db_map, {}).get(value_list_name) - if not value_list: - return [f"Unknown value list name {value_list_name}"] if value_list_name else [] - item["parameter_value_list_id"] = value_list["id"] - else: - item["parameter_value_list_id"] = None - return [] - - -class FillInEntityClassIdMixin(ConvertToDBMixin): - """Fills in entity_class ids.""" - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_ent_cls_lookup = dict() - - def build_lookup_dictionary(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping - """ - super().build_lookup_dictionary(db_map_data) - # Group data by name - db_map_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - entity_class_name = item.get(self.entity_class_name_key) - db_map_names.setdefault(db_map, set()).add(entity_class_name) - # Build lookup dict - self._db_map_ent_cls_lookup.clear() - for db_map, names in db_map_names.items(): - for name in names: - item = self.db_mngr.get_item_by_field(db_map, self.entity_class_type, "name", name, only_visible=False) - if item: - self._db_map_ent_cls_lookup.setdefault(db_map, {})[name] = item - - def _fill_in_entity_class_id(self, item, db_map): - """Fills in the entity_class id in the given db item. - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - entity_class_name = item.pop(self.entity_class_name_key, None) - entity_class = self._db_map_ent_cls_lookup.get(db_map, {}).get(entity_class_name) - if not entity_class: - return [f"Unknown entity_class {entity_class_name}"] if entity_class_name else [] - item[self.entity_class_id_key] = entity_class.get("id") - return [] - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._fill_in_entity_class_id(item, db_map) - return item, err1 + err2 - - -class FillInEntityIdsMixin(ConvertToDBMixin): - """Fills in entity ids.""" - - _add_entities_on_the_fly = False - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_ent_lookup = dict() - - def build_lookup_dictionary(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping - """ - super().build_lookup_dictionary(db_map_data) - # Group data by name - db_map_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - name = item.get(self.entity_name_key) - db_map_names.setdefault(db_map, set()).add(name) - # Build lookup dict - self._db_map_ent_lookup.clear() - for db_map, names in db_map_names.items(): - for name in names: - items = self.db_mngr.get_items_by_field( - db_map, self.entity_type, self.entity_name_key_in_cache, name, only_visible=False - ) - if items: - self._db_map_ent_lookup.setdefault(db_map, {})[name] = items - - def _fill_in_entity_ids(self, item, db_map): - """Fills in all possible entity ids keyed by entity_class id in the given db item - (as there can be more than one entity for the same name). - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - name = item.pop(self.entity_name_key, None) - items = self._db_map_ent_lookup.get(db_map, {}).get(name) - if not items: - return [f"Unknown entity {name}"] if name and not self._add_entities_on_the_fly else [] - item["entity_ids"] = {x["class_id"]: x["id"] for x in items} - return [] - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._fill_in_entity_ids(item, db_map) - return item, err1 + err2 - - -class FillInParameterDefinitionIdsMixin(ConvertToDBMixin): - """Fills in parameter_definition ids.""" - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_param_lookup = dict() - - def build_lookup_dictionary(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping - """ - super().build_lookup_dictionary(db_map_data) - # Group data by name - db_map_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - name = item.get("parameter_name") - db_map_names.setdefault(db_map, set()).add(name) - # Build lookup dict - self._db_map_param_lookup.clear() - for db_map, names in db_map_names.items(): - for name in names: - items = [ - x - for x in self.db_mngr.get_items_by_field( - db_map, "parameter_definition", "parameter_name", name, only_visible=False - ) - if self.entity_class_id_key in x - ] - if items: - self._db_map_param_lookup.setdefault(db_map, {})[name] = items - - def _fill_in_parameter_ids(self, item, db_map): - """Fills in all possible parameter_definition ids keyed by entity_class id in the given db item - (as there can be more than one parameter_definition for the same name). - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - name = item.pop("parameter_name", None) - items = self._db_map_param_lookup.get(db_map, {}).get(name) - if not items: - return [f"Unknown parameter {name}"] if name else [] - item["parameter_ids"] = {x[self.entity_class_id_key]: x["id"] for x in items} - return [] - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._fill_in_parameter_ids(item, db_map) - return item, err1 + err2 - - -class InferEntityClassIdMixin(ConvertToDBMixin): - """Infers entity class ids.""" - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._infer_and_fill_in_entity_class_id(item, db_map) - return item, err1 + err2 - - def _infer_and_fill_in_entity_class_id(self, item, db_map): - """Fills the entity_class id in the given db item, by intersecting entity ids and parameter ids. - Then picks the correct entity id and parameter_definition id. - Also sets the inferred entity_class name in the model. - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - row = item.pop("row") - entity_ids = item.pop("entity_ids", {}) - parameter_ids = item.pop("parameter_ids", {}) - if self.entity_class_id_key not in item: - if not entity_ids: - entity_class_ids = set(parameter_ids.keys()) - elif not parameter_ids: - entity_class_ids = set(entity_ids.keys()) - else: - entity_class_ids = entity_ids.keys() & parameter_ids.keys() - if len(entity_class_ids) != 1: - # entity_class id not in the item and not inferrable, good bye - return ["Unable to infer entity_class."] - entity_class_id = entity_class_ids.pop() - item[self.entity_class_id_key] = entity_class_id - entity_class_name = self.db_mngr.get_item( - db_map, self.entity_class_type, entity_class_id, only_visible=False - )["name"] - # TODO: Try to find a better place for this, and emit dataChanged - self._main_data[row][self.header.index(self.entity_class_name_key)] = entity_class_name - # At this point we're sure the entity_class_id is there - entity_class_id = item[self.entity_class_id_key] - entity_id = entity_ids.get(entity_class_id) - parameter_definition_id = parameter_ids.get(entity_class_id) - if entity_id: - item[self.entity_id_key] = entity_id - if parameter_definition_id: - item["parameter_definition_id"] = parameter_definition_id - return [] - - -class ImposeEntityClassIdMixin(ConvertToDBMixin): - """Imposes entity class ids.""" - - def _convert_to_db(self, item, db_map): - """Returns a db item (id-based) from the given model item (name-based). - - Args: - item (dict): the model item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db item - list: error log - """ - item, err1 = super()._convert_to_db(item, db_map) - err2 = self._impose_entity_class_id(item, db_map) - return item, err1 + err2 - - def _impose_entity_class_id(self, item, db_map): - """Imposes the entity_class id from the model, to pick the correct entity id and parameter_definition id. - - Args: - item (dict): the db item - db_map (DiffDatabaseMapping): the database where the given item belongs - - Returns: - list: error log - """ - entity_ids = item.pop("entity_ids", {}) - parameter_ids = item.pop("parameter_ids", {}) - entity_id = entity_ids.get(self.entity_class_id) - parameter_definition_id = parameter_ids.get(self.entity_class_id) - if entity_id: - item[self.entity_id_key] = entity_id - if parameter_definition_id: - item["parameter_definition_id"] = parameter_definition_id - return [] - - -class MakeRelationshipOnTheFlyMixin: - """Makes relationships on the fly.""" - - def __init__(self, *args, **kwargs): - """Initializes lookup dicts.""" - super().__init__(*args, **kwargs) - self._db_map_obj_lookup = dict() - self._db_map_rel_cls_lookup = dict() - self._db_map_existing_rels = dict() - - @staticmethod - def _make_unique_relationship_id(item): - """Returns a unique name-based identifier for db relationships.""" - return (item["class_name"], item["object_name_list"]) - - def build_lookup_dictionaries(self, db_map_data): - """Builds a name lookup dictionary for the given data. - - Args: - db_map_data (dict): lists of model items keyed by DiffDatabaseMapping. - """ - # Group data by name - db_map_object_names = dict() - db_map_rel_cls_names = dict() - for db_map, items in db_map_data.items(): - for item in items: - object_name_list = item.get("object_name_list") - if object_name_list: - db_map_object_names.setdefault(db_map, set()).update(object_name_list) - relationship_class_name = item.get("relationship_class_name") - db_map_rel_cls_names.setdefault(db_map, set()).add(relationship_class_name) - # Build lookup dicts - self._db_map_obj_lookup.clear() - for db_map, names in db_map_object_names.items(): - for name in names: - item = self.db_mngr.get_item_by_field(db_map, "object", "name", name, only_visible=False) - if item: - self._db_map_obj_lookup.setdefault(db_map, {})[name] = item - self._db_map_rel_cls_lookup.clear() - for db_map, names in db_map_rel_cls_names.items(): - for name in names: - item = self.db_mngr.get_item_by_field(db_map, "relationship_class", "name", name, only_visible=False) - if item: - self._db_map_rel_cls_lookup.setdefault(db_map, {})[name] = item - self._db_map_existing_rels = { - db_map: { - self._make_unique_relationship_id(x) - for x in self.db_mngr.get_items(db_map, "relationship", only_visible=False) - } - for db_map in self._db_map_obj_lookup.keys() | self._db_map_rel_cls_lookup.keys() - } - - def _make_relationship_on_the_fly(self, item, db_map): - """Returns a database relationship item (id-based) from the given model parameter_value item (name-based). - - Args: - item (dict): the model parameter_value item - db_map (DiffDatabaseMapping): the database where the resulting item belongs - - Returns: - dict: the db relationship item - list: error log - """ - relationship_class_name = item.get("relationship_class_name") - object_name_list = item.get("object_name_list") - if not object_name_list: - return None, [] - relationships = self._db_map_existing_rels.get(db_map, set()) - if (relationship_class_name, object_name_list) in relationships: - return None, [] - relationship_class = self._db_map_rel_cls_lookup.get(db_map, {}).get(relationship_class_name) - if not relationship_class: - return None, [f"Unknown relationship_class {relationship_class_name}"] if relationship_class_name else [] - object_id_list = [] - for name in object_name_list: - object_ = self._db_map_obj_lookup.get(db_map, {}).get(name) - if not object_: - return None, [f"Unknown object {name}"] - object_id_list.append(object_["id"]) - relationship_name = relationship_class_name + "__" + "_".join(object_name_list) - return {"class_id": relationship_class["id"], "object_id_list": object_id_list, "name": relationship_name}, [] diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py index bd5cbdce3..03ae1dbfc 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_item.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Tree items for parameter_value lists. -""" - +"""Tree items for parameter_value lists.""" from PySide6.QtCore import Qt from PySide6.QtGui import QIcon from spinetoolbox.mvcmodels.shared import PARSED_ROLE @@ -41,10 +39,10 @@ def fetch_item_type(self): return "parameter_value_list" def empty_child(self): - return ListItem() + return ListItem(self._model) def _make_child(self, id_): - return ListItem(id_) + return ListItem(self._model, id_) class ListItem( @@ -52,10 +50,6 @@ class ListItem( ): """A list item.""" - def __init__(self, identifier=None, name=None): - super().__init__(identifier=identifier) - self._name = name - @property def item_type(self): return "parameter_value_list" @@ -65,19 +59,19 @@ def fetch_item_type(self): return "list_value" def _make_item_data(self): - return {"name": "Type new list name here..." if self._name is None else self._name} + return {"name": "Type new list name here..."} def _do_set_up(self): - if not self.id and not self._name: + if not self.id: return super()._do_set_up() # pylint: disable=no-self-use def empty_child(self): - return ValueItem() + return ValueItem(self._model) def _make_child(self, id_): - return ValueItem(id_) + return ValueItem(self._model, id_) def accepts_item(self, item, db_map): return item["parameter_value_list_id"] == self.id @@ -110,7 +104,8 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole): if role == Qt.ItemDataRole.DisplayRole and not self.id: return "Enter new list value here..." if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole, PARSED_ROLE): - return self.db_mngr.get_value(self.db_map, self.item_type, self.id, role=role) + item = self.db_mngr.get_item(self.db_map, self.item_type, self.id) + return self.db_mngr.get_value(self.db_map, item, role=role) return super().data(column, role) def list_index(self): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py index 5ba1787d7..a23c19e5e 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/parameter_value_list_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A tree model for parameter_value lists. -""" - +"""A tree model for parameter_value lists.""" from PySide6.QtCore import Qt, QModelIndex from .tree_model_base import TreeModelBase from .parameter_value_list_item import DBItem @@ -21,13 +19,8 @@ class ParameterValueListModel(TreeModelBase): """A model to display parameter_value_list data in a tree view.""" - @staticmethod - def _make_db_item(db_map): - return DBItem(db_map) - - @staticmethod - def _top_children(): - return [] + def _make_db_item(self, db_map): + return DBItem(self, db_map) def columnCount(self, parent=QModelIndex()): """Returns the number of columns under the given parent. Always 1.""" diff --git a/spinetoolbox/spine_db_editor/mvcmodels/pivot_model.py b/spinetoolbox/spine_db_editor/mvcmodels/pivot_model.py index e9a787bb5..3017e2075 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/pivot_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/pivot_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Provides PivotModel. -""" - +"""Provides PivotModel.""" import operator from ...helpers import tuple_itemgetter @@ -147,6 +145,8 @@ def _index_key_getter(self, indexes): Callable: an itemgetter """ keys = tuple(self.index_ids.index(i) for i in indexes if i in self.index_ids) + if not keys: + return lambda _: () return tuple_itemgetter(operator.itemgetter(*keys), len(keys)) def _get_unique_index_values(self, indexes): @@ -166,12 +166,19 @@ def _get_unique_index_values(self, indexes): result = {index_getter(k): None for k in self._data if frozen_getter(k) == self.frozen_value} else: result = {index_getter(k): None for k in self._data} - return sorted( - (x for x in result if None not in x), - key=lambda x: tuple( - self.top_left_headers[header_name].header_data(header_id) for header_name, header_id in zip(indexes, x) - ), - ) + accepted = {} + headers = self.top_left_headers + for x in result: + sort_keys = [] + for header_name, header_id in zip(indexes, x): + header = headers[header_name] + if not header.accepts(header_id): + break + sort_key = header.header_data(header_id) + sort_keys.append(sort_key if sort_key is not None else "") + else: + accepted[x] = sort_keys + return [item[0] for item in sorted(accepted.items(), key=operator.itemgetter(1))] def set_pivot(self, rows, columns, frozen, frozen_value): """Sets pivot.""" @@ -198,12 +205,12 @@ def set_frozen_value(self, value): """Sets values for the frozen indexes. Args: - value (list of str): + value (tuple of str): """ - if len(value) != len(self.pivot_frozen): - raise ValueError("'value' must have same length as 'self.pivot_frozen'") if value == self.frozen_value: return + if len(value) != len(self.pivot_frozen): + raise ValueError("'value' must have same length as 'self.pivot_frozen'") self.set_pivot(self.pivot_rows, self.pivot_columns, self.pivot_frozen, value) def set_frozen(self, frozen): @@ -254,7 +261,7 @@ def row_key(self, row): return len(self.pivot_rows) * (None,) if row == 0: return () - raise IndexError('index out of range for current row pivot') + raise IndexError("index out of range for current row pivot") def column_key(self, column): if self.pivot_columns: @@ -263,7 +270,7 @@ def column_key(self, column): return len(self.pivot_columns) * (None,) if column == 0: return () - raise IndexError('index out of range for current column pivot') + raise IndexError("index out of range for current column pivot") @property def rows(self): diff --git a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py index e20139d7b..37e191e03 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/pivot_table_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,19 +10,18 @@ # this program. If not, see . ###################################################################################################################### -""" -Provides pivot table models for the Tabular View. -""" +"""Provides pivot table models for the Tabular View.""" from collections import defaultdict from contextlib import suppress from functools import partial - +from itertools import product +from typing import Iterable from PySide6.QtCore import Qt, Signal, Slot, QTimer, QAbstractTableModel, QModelIndex, QSortFilterProxyModel from PySide6.QtGui import QFont - from spinedb_api import DatabaseMapping -from spinedb_api.parameter_value import join_value_and_type, split_value_and_type -from spinetoolbox.helpers import DB_ITEM_SEPARATOR, parameter_identifier +from spinedb_api.helpers import name_from_elements +from spinedb_api.parameter_value import IndexedValue, join_value_and_type, split_value_and_type +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, parameter_identifier, plain_to_tool_tip from spinetoolbox.fetch_parent import FlexibleFetchParent from .colors import FIXED_FIELD_COLOR, PIVOT_TABLE_HEADER_COLOR from .pivot_model import PivotModel @@ -58,7 +58,21 @@ def _get_header_data_from_db(self, item_type, header_id, field_name, role): if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): return item.get(field_name) if role == Qt.ItemDataRole.ToolTipRole: - return item.get("description", "No description") + return plain_to_tool_tip(item.get("description", "No description.")) + + @staticmethod + def accepts(header_id): + """Tests if header id is valid. + + Args: + header_id (Any): header id + + Returns: + bool: True if id is valid, False otherwise + """ + if isinstance(header_id, Iterable): + return all(id_ is not None for id_ in header_id[1:]) + return header_id is not None def header_data(self, header_id, role=Qt.ItemDataRole.DisplayRole): """Returns header data for given id. @@ -96,8 +110,8 @@ def add_data(self, names, db_map): raise NotImplementedError() -class TopLeftObjectHeaderItem(TopLeftHeaderItem): - """A top left header for object_class.""" +class TopLeftEntityHeaderItem(TopLeftHeaderItem): + """A top left header for an entity class.""" def __init__(self, model, rank, class_name, class_id): super().__init__(model) @@ -107,21 +121,25 @@ def __init__(self, model, rank, class_name, class_id): @property def header_type(self): - return "object" + return "entity" @property def name(self): return self._name + @property + def class_id(self): + return self._class_id + def header_data(self, header_id, role=Qt.ItemDataRole.DisplayRole): """See base class.""" - return self._get_header_data_from_db("object", header_id, "name", role) + return self._get_header_data_from_db("entity", header_id, "name", role) def update_data(self, db_map_data): """See base class.""" if not db_map_data: return False - self.db_mngr.update_objects(db_map_data) + self.db_mngr.update_entities(db_map_data) return True def add_data(self, names, db_map): @@ -130,7 +148,7 @@ def add_data(self, names, db_map): return False class_id = self._class_id[db_map] db_map_data = {db_map: [{"name": name, "class_id": class_id} for name in names]} - self.db_mngr.add_objects(db_map_data) + self.db_mngr.add_entities(db_map_data) return True @@ -147,7 +165,7 @@ def name(self): def header_data(self, header_id, role=Qt.ItemDataRole.DisplayRole): """See base class.""" - return self._get_header_data_from_db("parameter_definition", header_id, "parameter_name", role) + return self._get_header_data_from_db("parameter_definition", header_id, "name", role) def update_data(self, db_map_data): """See base class.""" @@ -352,7 +370,11 @@ def db_maps(self): def reset_fetch_parents(self): for parent in self._fetch_parents(): - parent.reset_fetching(None) + parent.reset() + + def set_fetch_parents_non_obsolete(self): + for parent in self._fetch_parents(): + parent.set_obsolete(False) def _fetch_parents(self): """Yields fetch parents for this model. @@ -367,8 +389,9 @@ def canFetchMore(self, _): return False result = False for fetch_parent in self._fetch_parents(): - for db_map in self._parent.db_maps: - result |= self.db_mngr.can_fetch_more(db_map, fetch_parent) + if not fetch_parent.is_fetched: + for db_map in self._parent.db_maps: + result |= self.db_mngr.can_fetch_more(db_map, fetch_parent) return result def fetchMore(self, _): @@ -475,9 +498,9 @@ def remove_from_model(self, data): if not data: return row_count, column_count = self.model.remove_from_model(data) - frozen_values = self.model.frozen_values(data) - if frozen_values: - self.frozen_values_removed.emit(frozen_values) + removed_frozen_values = self.model.frozen_values(data) - self.model.frozen_values(self.model._data) + if removed_frozen_values: + self.frozen_values_removed.emit(removed_frozen_values) if row_count > 0: first = self.headerRowCount() self.beginRemoveRows(QModelIndex(), first, first + row_count - 1) @@ -560,7 +583,7 @@ def x_parameter_name(self): header_ids = self._header_ids(0, pivot_column) _, parameter_id = header_ids[-3] db_map = header_ids[-1] - parameter_name = self.db_mngr.get_item(db_map, "parameter_definition", parameter_id).get("parameter_name", "") + parameter_name = self.db_mngr.get_item(db_map, "parameter_definition", parameter_id).get("name", "") return parameter_name def headerRowCount(self): @@ -777,7 +800,7 @@ def _color_data(self, index): def _text_alignment_data(self, index): # pylint: disable=no-self-use return None - def _header_data(self, index, role=Qt.ItemDataRole.DisplayRole): + def _header_data(self, index): header_id = self._header_id(index) top_left_id = self.top_left_id(index) return self._header_name(top_left_id, header_id) @@ -785,6 +808,17 @@ def _header_data(self, index, role=Qt.ItemDataRole.DisplayRole): def _header_name(self, top_left_id, header_id): return self.top_left_headers[top_left_id].header_data(header_id) + def get_db_map_entities(self): + """Returns a dict mapping db maps to a list of dict entity items in the current class. + + Returns: + dict + """ + return { + db_map: self.db_mngr.get_items_by_field(db_map, "entity", "class_id", class_id) + for db_map, class_id in self._parent.current_class_id.items() + } + def _data(self, index, role): raise NotImplementedError() @@ -795,7 +829,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): if self.index_in_left(index): return self.model.pivot_columns[index.row()] if self.index_in_headers(index): - return self._header_data(index, role) + return self._header_data(index) if self.index_in_data(index): return self._data(index, role) if "database" not in self.model.pivot_frozen: @@ -911,6 +945,11 @@ def _batch_set_empty_header_data(self, header_data, get_top_left_id): success = success or header_item.add_data(names, db_map) return success + def tear_down(self): + """Sets fetch parents obsolete preventing further updates.""" + for fetch_parent in self._fetch_parents(): + fetch_parent.set_obsolete(True) + class ParameterValuePivotTableModel(PivotTableModelBase): """A model for the pivot table in parameter_value input type.""" @@ -921,8 +960,16 @@ def __init__(self, parent): parent (SpineDBEditor) """ super().__init__(parent) + self._entity_class_fetch_parent = FlexibleFetchParent( + "entity_class", + handle_items_added=self._handle_entity_classes_added, + handle_items_removed=self._handle_entity_classes_removed, + handle_items_updated=lambda _: self._parent.refresh_views(), + accepts_item=self._parent.accepts_entity_class_item, + owner=self, + ) self._entity_fetch_parent = FlexibleFetchParent( - None, + "entity", handle_items_added=self._handle_entities_added, handle_items_removed=self._handle_entities_removed, handle_items_updated=lambda _: self._parent.refresh_views(), @@ -954,6 +1001,17 @@ def __init__(self, parent): owner=self, ) + def _handle_entity_classes_added(self, db_map_data): + pass + + def _handle_entity_classes_removed(self, db_map_data): + for header_item in self.model.top_left_headers.values(): + if isinstance(header_item, TopLeftEntityHeaderItem): + for db_map, class_id in header_item.class_id.items(): + if any(class_item["id"] == class_id for class_item in db_map_data[db_map]): + self.clear_model() + return + def _handle_entities_added(self, db_map_data): data = self._load_empty_parameter_value_data(db_map_entities=db_map_data) self.add_to_model(data) @@ -982,7 +1040,7 @@ def _handle_parameter_values_added(self, db_map_data): def _handle_parameter_values_removed(self, db_map_data): data = self._load_full_parameter_value_data(db_map_parameter_values=db_map_data, action="remove") - self.remove_from_model(data) + self.update_model(data) def _handle_alternatives_added(self, db_map_data): db_map_alternative_ids = {db_map: [(db_map, a["id"]) for a in items] for db_map, items in db_map_data.items()} @@ -994,11 +1052,89 @@ def _handle_alternatives_removed(self, db_map_data): data = self._load_empty_parameter_value_data(db_map_alternative_ids=db_map_alternative_ids) self.remove_from_model(data) - def _load_empty_parameter_value_data(self, *args, **kwargs): - return self._parent.load_empty_parameter_value_data(*args, **kwargs) + def _load_empty_parameter_value_data( + self, db_map_entities=None, db_map_parameter_ids=None, db_map_alternative_ids=None + ): + """Returns a dict containing all possible combinations of entities and parameters for the current class + in all db_maps. + + Args: + db_map_entities (dict, optional): if given, only load data for these db maps and entities + db_map_parameter_ids (dict, optional): if given, only load data for these db maps and parameter definitions + db_map_alternative_ids (dict, optional): if given, only load data for these db maps and alternatives + + Returns: + dict: Key is a tuple object_id, ..., parameter_id, value is None. + """ + ( + db_map_entity_ids, + db_map_parameter_ids, + db_map_alternative_ids, + ) = self._all_combination_for_empty_parameter_value( + db_map_entities, db_map_parameter_ids, db_map_alternative_ids + ) + return { + entity_id + (parameter_id, alt_id, db_map): None + for db_map in self.db_maps + for entity_id in db_map_entity_ids.get(db_map, []) + for parameter_id in db_map_parameter_ids.get(db_map, []) + for alt_id in db_map_alternative_ids.get(db_map, []) + } + + def _all_combination_for_empty_parameter_value(self, db_map_entities, db_map_parameter_ids, db_map_alternative_ids): + if db_map_entities is None: + db_map_entities = self.get_db_map_entities() + if db_map_parameter_ids is None: + db_map_parameter_ids = { + db_map: [(db_map, id_) for id_ in ids] + for db_map, ids in self._get_db_map_parameter_value_or_def_ids("parameter_definition").items() + } + if db_map_alternative_ids is None: + db_map_alternative_ids = { + db_map: [ + (db_map, id_) for a in self.db_mngr.get_items(db_map, "alternative") if (id_ := a["id"]) is not None + ] + for db_map in self.db_maps + } + db_map_entity_ids = { + db_map: [ + id_tuple + for e in entities + if (id_tuple := tuple((db_map, id_) for id_ in e["element_id_list"] or (e["id"],))) + ] + for db_map, entities in db_map_entities.items() + } + if not any(db_map_entity_ids.values()) and ( + current_dimension_id_list := self._parent.current_dimension_id_list + ): + db_map_entity_ids = { + db_map: [tuple((db_map, None) for _ in current_dimension_id_list)] for db_map in self.db_maps + } + if not any(db_map_parameter_ids.values()): + db_map_parameter_ids = {db_map: [(db_map, None)] for db_map in self.db_maps} + if not any(db_map_alternative_ids.values()): + db_map_alternative_ids = {db_map: [(db_map, None)] for db_map in self.db_maps} + return db_map_entity_ids, db_map_parameter_ids, db_map_alternative_ids + + def _load_full_parameter_value_data(self, db_map_parameter_values=None, action="add"): + """Returns a dict of parameter values for the current class. + + Args: + db_map_parameter_values (list, optional) + action (str) - def _load_full_parameter_value_data(self, *args, **kwargs): - return self._parent.load_full_parameter_value_data(*args, **kwargs) + Returns: + dict: Key is a tuple object_id, ..., parameter_id, value is the parameter_value. + """ + if db_map_parameter_values is None: + db_map_parameter_values = self._get_db_map_parameter_values_or_defs("parameter_value") + get_id = _make_get_id(action) + return { + tuple((db_map, id_) for id_ in x["element_id_list"] or (x["entity_id"],)) + + ((db_map, x["parameter_id"]), (db_map, x["alternative_id"]), db_map): get_id(db_map, x) + for db_map, items in db_map_parameter_values.items() + for x in items + } @property def item_type(self): @@ -1009,33 +1145,42 @@ def _fetch_parents(self): yield self._alternative_fetch_parent yield self._parameter_definition_fetch_parent yield self._entity_fetch_parent + yield self._entity_class_fetch_parent - def reset_fetch_parents(self): - super().reset_fetch_parents() - if self._parent.current_class_type == "object_class": - self._entity_fetch_parent.fetch_item_type = "object" - elif self._parent.current_class_type == "relationship_class": - self._entity_fetch_parent.fetch_item_type = "relationship" + def _db_map_element_ids(self, header_ids): + entity_indexes = [ + k for k, h in enumerate(self.top_left_headers.values()) if isinstance(h, TopLeftEntityHeaderItem) + ] + return header_ids[-1], [header_ids[k][1] for k in entity_indexes] - def db_map_object_ids(self, index): + def db_map_entity_ids(self, indexes): """ - Returns db_map and object ids for given index. Used by PivotTableView. + Returns db_map and entity ids for given indexes. Used by PivotTableView. + + Args: + indexes (list of QModelIndex): indexes corresponding to entity items Returns: - DatabaseMapping, list + dict: mapping DatabaseMapping to set of entity ids """ - row, column = self.map_to_pivot(index) - header_ids = self._header_ids(row, column) - return self._db_map_object_ids(header_ids) - - def _db_map_object_ids(self, header_ids): - object_indexes = [ - k for k, h in enumerate(self.top_left_headers.values()) if isinstance(h, TopLeftObjectHeaderItem) - ] - return header_ids[-1], [header_ids[k][1] for k in object_indexes] + db_map_entity_lookup = { + db_map: {ent["element_id_list"]: ent["id"] for ent in ents} + for db_map, ents in self.get_db_map_entities().items() + } + db_map_entity_ids = {} + for index in indexes: + row, column = self.map_to_pivot(index) + if not self._parent.first_current_entity_class["dimension_id_list"]: + db_map, id_ = self._header_id(index) + else: + header_ids = self._header_ids(row, column) + db_map, element_id_list = self._db_map_element_ids(header_ids) + id_ = db_map_entity_lookup.get(db_map, {}).get(tuple(element_id_list)) + db_map_entity_ids.setdefault(db_map, set()).add(id_) + return db_map_entity_ids def all_header_names(self, index): - """Returns the object, parameter, alternative, and db names corresponding to the given data index. + """Returns the entity, parameter, alternative, and db names corresponding to the given data index. Args: index (QModelIndex) @@ -1048,14 +1193,14 @@ def all_header_names(self, index): """ row, column = self.map_to_pivot(index) header_ids = self._header_ids(row, column) - _, objects_ids = self._db_map_object_ids(header_ids) + _, entity_ids = self._db_map_element_ids(header_ids) _, parameter_id = header_ids[-3] _, alternative_id = header_ids[-2] db_map = header_ids[-1] - object_names = [self.db_mngr.get_item(db_map, "object", id_)["name"] for id_ in objects_ids] - parameter_name = self.db_mngr.get_item(db_map, "parameter_definition", parameter_id).get("parameter_name", "") + entity_names = [self.db_mngr.get_item(db_map, "entity", id_)["name"] for id_ in entity_ids] + parameter_name = self.db_mngr.get_item(db_map, "parameter_definition", parameter_id).get("name", "") alternative_name = self.db_mngr.get_item(db_map, "alternative", alternative_id).get("name", "") - return object_names, parameter_name, alternative_name, db_map.codename + return entity_names, parameter_name, alternative_name, db_map.codename def index_name(self, index): """Returns a string that concatenates the object and parameter names corresponding to the given data index. @@ -1069,8 +1214,8 @@ def index_name(self, index): """ if not self.index_in_data(index): return "" - object_names, parameter_name, alternative_name, db_name = self.all_header_names(index) - return parameter_identifier(db_name, parameter_name, object_names, alternative_name) + entity_names, parameter_name, alternative_name, db_name = self.all_header_names(index) + return parameter_identifier(db_name, parameter_name, entity_names, alternative_name) def column_name(self, column): """Returns a string that concatenates the object and parameter names corresponding to the given column. @@ -1091,10 +1236,10 @@ def column_name(self, column): def call_reset_model(self, pivot=None): """See base class.""" - object_class_ids = self._parent.current_object_class_ids + dimension_ids = self._parent.current_dimension_ids data = {} top_left_headers = [ - TopLeftObjectHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(object_class_ids.items()) + TopLeftEntityHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(dimension_ids.items()) ] top_left_headers += [TopLeftParameterHeaderItem(self)] top_left_headers += [TopLeftAlternativeHeaderItem(self)] @@ -1125,28 +1270,20 @@ def _data(self, index, role): if data[0][0] is None: return None db_map, id_ = data[0][0] - return self.db_mngr.get_value(db_map, "parameter_value", id_, role) + item = self.db_mngr.get_item(db_map, "parameter_value", id_) + return self.db_mngr.get_value(db_map, item, role) def _do_batch_set_inner_data(self, row_map, column_map, data, values): return self._batch_set_parameter_value_data(row_map, column_map, data, values) - def _object_parameter_value_to_add(self, db_map, header_ids, value_and_type): - value, value_type = split_value_and_type(value_and_type) - return dict( - object_class_id=self._parent.current_class_id[db_map], - object_id=header_ids[0], - parameter_definition_id=header_ids[-2], - value=value, - type=value_type, - alternative_id=header_ids[-1], + def _entity_parameter_value_to_add(self, db_map, header_ids, value_and_type, ent_id_lookup=None): + entity_id = ( + header_ids[0] if ent_id_lookup is None else ent_id_lookup[db_map, tuple(id_ for id_ in header_ids[:-2])] ) - - def _relationship_parameter_value_to_add(self, db_map, header_ids, value_and_type, rel_id_lookup): - object_id_list = tuple(id_ for id_ in header_ids[:-2]) value, value_type = split_value_and_type(value_and_type) return dict( - relationship_class_id=self._parent.current_class_id[db_map], - relationship_id=rel_id_lookup[db_map, object_id_list], + entity_class_id=self._parent.current_class_id[db_map], + entity_id=entity_id, parameter_definition_id=header_ids[-2], value=value, type=value_type, @@ -1154,21 +1291,18 @@ def _relationship_parameter_value_to_add(self, db_map, header_ids, value_and_typ ) def _make_parameter_value_to_add(self): - if self._parent.current_class_type == "object_class": - return self._object_parameter_value_to_add - if self._parent.current_class_type == "relationship_class": - db_map_relationships = { - db_map: self.db_mngr.get_items_by_field(db_map, "relationship", "class_id", class_id) - for db_map, class_id in self._parent.current_class_id.items() - } - rel_id_lookup = { - (db_map, x["object_id_list"]): x["id"] - for db_map, relationships in db_map_relationships.items() - for x in relationships - } - return lambda db_map, header_ids, value, rel_id_lookup=rel_id_lookup: self._relationship_parameter_value_to_add( - db_map, header_ids, value, rel_id_lookup - ) + if not self._parent.first_current_entity_class["dimension_id_list"]: + return self._entity_parameter_value_to_add + db_map_entities = { + db_map: self.db_mngr.get_items_by_field(db_map, "entity", "class_id", class_id) + for db_map, class_id in self._parent.current_class_id.items() + } + ent_id_lookup = { + (db_map, x["element_id_list"]): x["id"] for db_map, entities in db_map_entities.items() for x in entities + } + return lambda db_map, header_ids, value, ent_id_lookup=ent_id_lookup: self._entity_parameter_value_to_add( + db_map, header_ids, value, ent_id_lookup + ) @staticmethod def _parameter_value_to_update(id_, header_ids, value_and_type): @@ -1239,10 +1373,61 @@ def get_set_data_delayed(self, index): {db_map: [self._parameter_value_to_update(id_, header_ids, join_value_and_type(*value_type_tup))]} ) + def _get_db_map_parameter_value_or_def_ids(self, item_type): + """Returns a dict mapping db maps to a list of integer parameter (value or def) ids from the current class. + + Args: + item_type (str): either "parameter_value" or "parameter_definition" + + Returns: + dict + """ + current_class_id = self._parent.current_class_id + return { + db_map: [x["id"] for x in self.db_mngr.get_items_by_field(db_map, item_type, "entity_class_id", class_id)] + for db_map, class_id in current_class_id.items() + } + + def _get_db_map_parameter_values_or_defs(self, item_type): + """Returns a dict mapping db maps to list of dict parameter (value or def) items from the current class. + + Args: + item_type (str): either "parameter_value" or "parameter_definition" + + Returns: + dict + """ + db_map_ids = self._get_db_map_parameter_value_or_def_ids(item_type) + return { + db_map: [self.db_mngr.get_item(db_map, item_type, id_) for id_ in ids] for db_map, ids in db_map_ids.items() + } + + def load_full_parameter_value_data(self, db_map_parameter_values=None, action="add"): + """Returns a dict of parameter values for the current class. + + Args: + db_map_parameter_values (list, optional) + action (str) + + Returns: + dict: Key is a tuple object_id, ..., parameter_id, value is the parameter_value. + """ + if db_map_parameter_values is None: + db_map_parameter_values = self._get_db_map_parameter_values_or_defs("parameter_value") + get_id = _make_get_id(action) + return { + tuple((db_map, id_) for id_ in x["element_id_list"] or (x["entity_id"],)) + + ((db_map, x["parameter_id"]), (db_map, x["alternative_id"]), db_map): get_id(db_map, x) + for db_map, items in db_map_parameter_values.items() + for x in items + } + class IndexExpansionPivotTableModel(ParameterValuePivotTableModel): """A model for the pivot table in parameter index expansion input type.""" + INDEX_INSERTION_POINT = -3 + def __init__(self, parent): """ Args: @@ -1253,10 +1438,10 @@ def __init__(self, parent): def call_reset_model(self, pivot=None): """See base class.""" - object_class_ids = self._parent.current_object_class_ids + entity_class_ids = self._parent.current_dimension_ids data = {} top_left_headers = [ - TopLeftObjectHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(object_class_ids.items()) + TopLeftEntityHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(entity_class_ids.items()) ] self._index_top_left_header = TopLeftParameterIndexHeaderItem(self) top_left_headers += [ @@ -1277,6 +1462,12 @@ def call_reset_model(self, pivot=None): # The parameter index is not a column (it's either a row or frozen) pass + def emptyRowCount(self): + return 0 + + def emptyColumnCount(self): + return 0 + def flags(self, index): """Roles for data""" if self.index_in_data(index): @@ -1298,11 +1489,57 @@ def column_is_index_column(self, column): # The parameter index is not a column (it's either a row or frozen) return False - def _load_empty_parameter_value_data(self, *args, **kwargs): - return self._parent.load_empty_expanded_parameter_value_data(*args, **kwargs) + def _handle_parameter_values_removed(self, db_map_data): + data = self._load_full_parameter_value_data(db_map_parameter_values=db_map_data, action="remove") + self.remove_from_model(data) + + def _load_empty_parameter_value_data( + self, db_map_entities=None, db_map_parameter_ids=None, db_map_alternative_ids=None + ): + """Does not load the data since adding values in index expansion mode is disabled. - def _load_full_parameter_value_data(self, *args, **kwargs): - return self._parent.load_full_expanded_parameter_value_data(*args, **kwargs) + Args: + db_map_entities (dict, optional): mapping from database map to iterable of entity items + db_map_parameter_ids (dict, optional): mapping from database map + to iterable of parameter definition id tuples + db_map_alternative_ids (dict, optional): mapping from database map to iterable of alternative id tuples + + Returns: + dict: empty data + """ + return {} + + def _load_full_parameter_value_data(self, db_map_parameter_values=None, action="add"): + """Makes a dict of expanded parameter values for the current class. + + Args: + db_map_parameter_values (list, optional) + action (str) + + Returns: + dict: mapping from unique value id tuple to value tuple + """ + if db_map_parameter_values is None: + db_map_parameter_values = self._get_db_map_parameter_values_or_defs("parameter_value") + full_data = {} + get_id = _make_get_id(action) + for db_map, items in db_map_parameter_values.items(): + for item in items: + element_ids = tuple((db_map, id_) for id_ in item["element_id_list"]) + if not element_ids: + element_ids = ((db_map, item["entity_id"]),) + parameter_id = (db_map, item["parameter_id"]) + parsed_value = item["parsed_value"] + if isinstance(parsed_value, IndexedValue): + value_indexes = parsed_value.indexes + else: + value_indexes = ("",) + alternative_ids = (db_map, item["alternative_id"]) + for value_index in value_indexes: + full_data[element_ids + ((None, value_index), parameter_id, alternative_ids, db_map)] = get_id( + db_map, item + ) + return full_data def _data(self, index, role): row, column = self.map_to_pivot(index) @@ -1318,14 +1555,25 @@ def _data(self, index, role): @staticmethod def _parameter_value_to_update(id_, header_ids, value_and_type): value, value_type = split_value_and_type(value_and_type) - return {"id": id_, "value": value, "type": value_type, "index": header_ids[-3]} + return { + "id": id_, + "value": value, + "type": value_type, + "index": header_ids[IndexExpansionPivotTableModel.INDEX_INSERTION_POINT], + } def _update_parameter_values(self, db_map_data): self.db_mngr.update_expanded_parameter_values(db_map_data) + def _indexes(self, value): + if value is None: + return [] + db_map, id_ = value + return self.db_mngr.get_value_indexes(db_map, "parameter_value", id_) -class RelationshipPivotTableModel(PivotTableModelBase): - """A model for the pivot table in relationship input type.""" + +class ElementPivotTableModel(PivotTableModelBase): + """A model for the pivot table in element input type.""" def __init__(self, parent): """ @@ -1333,46 +1581,68 @@ def __init__(self, parent): parent (SpineDBEditor) """ super().__init__(parent) - self._relationship_fetch_parent = FlexibleFetchParent( - "relationship", - handle_items_added=self._handle_relationships_added, - handle_items_removed=self._handle_relationships_removed, + self._entity_fetch_parent = FlexibleFetchParent( + "entity", + handle_items_added=self._handle_entities_added, + handle_items_removed=self._handle_entities_removed, handle_items_updated=lambda _: self._parent.refresh_views(), accepts_item=self._parent.accepts_entity_item, owner=self, chunk_size=None, ) - self._object_fetch_parent = FlexibleFetchParent( - "object", - handle_items_added=self._handle_objects_added, - handle_items_removed=self._handle_objects_removed, + self._element_fetch_parent = FlexibleFetchParent( + "entity", + handle_items_added=self._handle_elements_added, + handle_items_removed=self._handle_elements_removed, handle_items_updated=lambda _: self._parent.refresh_views(), - accepts_item=self._parent.accepts_member_object_item, + accepts_item=self._parent.accepts_element_item, owner=self, ) - def _handle_relationships_added(self, db_map_data): - data = self._parent.load_full_relationship_data(db_map_relationships=db_map_data, action="add") + def _handle_entities_added(self, db_map_data): + data = self._load_full_element_data(db_map_entities=db_map_data, action="add") self.update_model(data) - def _handle_relationships_removed(self, db_map_data): - data = self._parent.load_full_relationship_data(db_map_relationships=db_map_data, action="remove") + def _handle_entities_removed(self, db_map_data): + data = self._load_full_element_data(db_map_entities=db_map_data, action="remove") self.update_model(data) - def _load_empty_relationship_data(self, db_map_data): - db_map_class_objects = dict() + def _load_empty_element_data(self, db_map_data): + if not self._parent.first_current_entity_class["dimension_id_list"]: + return {} + db_map_class_entities = {} for db_map, items in db_map_data.items(): - class_objects = db_map_class_objects[db_map] = dict() + class_entities = db_map_class_entities[db_map] = {} for item in items: - class_objects.setdefault(item["class_id"], []).append(item) - return self._parent.load_empty_relationship_data(db_map_class_objects=db_map_class_objects) + class_entities.setdefault(item["class_id"], []).append(item) + data = {} + for db_map in self.db_maps: + element_id_lists = [] + all_given_ids = set() + for db_map_dimension_id in self._parent.current_dimension_id_list: + dim_id = db_map_dimension_id.get(db_map) + elements = self.db_mngr.get_items_by_field(db_map, "entity", "class_id", dim_id) + ids = {item["id"]: None for item in elements} + given_elements = db_map_class_entities.get(db_map, {}).get(dim_id) + if given_elements is not None: + given_ids = {item["id"]: None for item in given_elements} + ids.update(given_ids) + all_given_ids.update(given_ids.keys()) + element_id_lists.append(list(ids.keys())) + db_map_data = { + tuple((db_map, id_) for id_ in element_ids) + (db_map,): None + for element_ids in product(*element_id_lists) + if not all_given_ids or all_given_ids.intersection(element_ids) + } + data.update(db_map_data) + return data - def _handle_objects_added(self, db_map_data): - data = self._load_empty_relationship_data(db_map_data) + def _handle_elements_added(self, db_map_data): + data = self._load_empty_element_data(db_map_data) self.add_to_model(data) - def _handle_objects_removed(self, db_map_data): - data = self._load_empty_relationship_data(db_map_data) + def _handle_elements_removed(self, db_map_data): + data = self._load_empty_element_data(db_map_data) self.remove_from_model(data) @property @@ -1380,15 +1650,15 @@ def item_type(self): return "relationship" def _fetch_parents(self): - yield self._object_fetch_parent - yield self._relationship_fetch_parent + yield self._element_fetch_parent + yield self._entity_fetch_parent def call_reset_model(self, pivot=None): """See base class.""" - object_class_ids = self._parent.current_object_class_ids + entity_class_ids = self._parent.current_dimension_ids data = {} top_left_headers = [ - TopLeftObjectHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(object_class_ids.items()) + TopLeftEntityHeaderItem(self, k, name, id_) for k, (name, id_) in enumerate(entity_class_ids.items()) ] top_left_headers += [TopLeftDatabaseHeaderItem(self)] self.top_left_headers = {h.name: h for h in top_left_headers} @@ -1417,16 +1687,13 @@ def _data(self, index, role): return bool(data[0][0]) def _do_batch_set_inner_data(self, row_map, column_map, data, values): - return self._batch_set_relationship_data(row_map, column_map, data, values) + return self._batch_set_entity_data(row_map, column_map, data, values) - def _batch_set_relationship_data(self, row_map, column_map, data, values): - def relationship_to_add(db_map, header_ids): - rel_cls_name = self.db_mngr.get_item( - db_map, "relationship_class", self._parent.current_class_id.get(db_map) - )["name"] - object_names = [self.db_mngr.get_item(db_map, "object", id_)["name"] for id_ in header_ids] - name = rel_cls_name + "_" + "__".join(object_names) - return dict(object_id_list=list(header_ids), class_id=self._parent.current_class_id.get(db_map), name=name) + def _batch_set_entity_data(self, row_map, column_map, data, values): + def entity_to_add(db_map, header_ids): + element_names = [self.db_mngr.get_item(db_map, "entity", id_)["name"] for id_ in header_ids] + name = name_from_elements(element_names) + return dict(element_id_list=list(header_ids), class_id=self._parent.current_class_id.get(db_map), name=name) to_add = {} to_remove = {} @@ -1436,19 +1703,40 @@ def relationship_to_add(db_map, header_ids): db_map = header_ids.pop() header_ids = [id_ for _, id_ in header_ids] if data[i][j] is None and values[row, column]: - item = relationship_to_add(db_map, header_ids) + item = entity_to_add(db_map, header_ids) to_add.setdefault(db_map, []).append(item) elif data[i][j] is not None and not values[row, column]: _, id_ = data[i][j] - to_remove.setdefault(db_map, {}).setdefault("relationship", []).append(id_) + to_remove.setdefault(db_map, {}).setdefault("entity", []).append(id_) if not to_add and not to_remove: return False if to_add: - self.db_mngr.add_relationships(to_add) + self.db_mngr.add_entities(to_add) if to_remove: self.db_mngr.remove_items(to_remove) return True + def _load_full_element_data(self, db_map_entities=None, action="add"): + """Returns a dict of entity elements in the current class. + + Args: + db_map_entities (dict, optional): a mapping from database map to entities in the current entity class + action (str): 'add' or 'remove' + + Returns: + dict: Key is db_map-object id tuple, value is relationship id. + """ + if not self._parent.first_current_entity_class.get("dimension_id_list", None): + return {} + if db_map_entities is None: + db_map_entities = self.get_db_map_entities() + get_id = _make_get_id(action) + return { + tuple((db_map, id_) for id_ in ent["element_id_list"]) + (db_map,): get_id(db_map, ent) + for db_map, entities in db_map_entities.items() + for ent in entities + } + class ScenarioAlternativePivotTableModel(PivotTableModelBase): """A model for the pivot table in scenario alternative input type.""" @@ -1482,19 +1770,19 @@ def __init__(self, parent): ) def _handle_scenarios_added(self, db_map_data): - data = self._parent.load_scenario_alternative_data(db_map_scenarios=db_map_data) + data = self._load_scenario_alternative_data(db_map_scenarios=db_map_data) self.add_to_model(data) def _handle_scenarios_removed(self, db_map_data): - data = self._parent.load_scenario_alternative_data(db_map_scenarios=db_map_data) + data = self._load_scenario_alternative_data(db_map_scenarios=db_map_data) self.remove_from_model(data) def _handle_alternatives_added(self, db_map_data): - data = self._parent.load_scenario_alternative_data(db_map_alternatives=db_map_data) + data = self._load_scenario_alternative_data(db_map_alternatives=db_map_data) self.add_to_model(data) def _handle_alternatives_removed(self, db_map_data): - data = self._parent.load_scenario_alternative_data(db_map_alternatives=db_map_data) + data = self._load_scenario_alternative_data(db_map_alternatives=db_map_data) self.remove_from_model(data) def _handle_scenario_alternatives_changed(self, db_map_data): @@ -1502,7 +1790,7 @@ def _handle_scenario_alternatives_changed(self, db_map_data): db_map: [self.db_mngr.get_item(db_map, "scenario", x["scenario_id"]) for x in items] for db_map, items in db_map_data.items() } - data = self._parent.load_scenario_alternative_data(db_map_scenarios=db_map_scenarios) + data = self._load_scenario_alternative_data(db_map_scenarios=db_map_scenarios) self.update_model(data) @property @@ -1587,6 +1875,34 @@ def _batch_set_scenario_alternative_data(self, row_map, column_map, data, values self.db_mngr.set_scenario_alternatives(db_map_items) return True + def _load_scenario_alternative_data(self, db_map_scenarios=None, db_map_alternatives=None): + """Returns a dict containing all scenario alternatives. + + Returns: + dict: Key is db_map-id tuple, value is None or rank. + """ + if db_map_scenarios is None: + db_map_scenarios = {db_map: self.db_mngr.get_items(db_map, "scenario") for db_map in self.db_maps} + if db_map_alternatives is None: + db_map_alternatives = {db_map: self.db_mngr.get_items(db_map, "alternative") for db_map in self.db_maps} + data = {} + for db_map in self.db_maps: + scenario_alternative_ranks = { + x["id"]: { + alt_id: k + 1 + for k, alt_id in enumerate(self.db_mngr.get_scenario_alternative_id_list(db_map, x["id"])) + } + for x in db_map_scenarios.get(db_map, []) + } + alternative_ids = [x["id"] for x in db_map_alternatives.get(db_map, [])] + db_map_data = { + ((db_map, scen_id), (db_map, alt_id), db_map): alternative_ranks.get(alt_id) + for scen_id, alternative_ranks in scenario_alternative_ranks.items() + for alt_id in alternative_ids + } + data.update(db_map_data) + return data + class PivotTableSortFilterProxy(QSortFilterProxyModel): model_data_changed = Signal() @@ -1621,7 +1937,9 @@ def clear_filter(self): def accept_index(self, index, index_ids): for i, identifier in zip(index, index_ids): valid = self.index_filters.get(identifier) - if valid is not None and i not in valid: + # NOTE: the tuple() below is to work-around TempId.__hash__, + # but we should fix the latter. + if valid is not None and i not in tuple(valid): return False return True @@ -1653,3 +1971,15 @@ def filterAcceptsColumn(self, source_column, source_parent): def batch_set_data(self, indexes, values): indexes = [self.mapToSource(index) for index in indexes] return self.sourceModel().batch_set_data(indexes, values) + + +def _make_get_id(action): + """Returns a function to compute the db_map-id tuple of an item. + + Args: + action (str): "add" or "remove" + + Returns: + Callable: function to compute db_map-id tuples + """ + return {"add": lambda db_map, x: (db_map, x["id"]), "remove": lambda db_map, x: None}[action] diff --git a/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py b/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py index 0a577ade8..4a3ec4437 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/scenario_item.py @@ -11,7 +11,6 @@ """Classes to represent items in scenario tree.""" from PySide6.QtCore import Qt - from .tree_item_utility import ( BoldTextMixin, EditableMixin, @@ -37,10 +36,10 @@ def fetch_item_type(self): return "scenario" def empty_child(self): - return ScenarioItem() + return ScenarioItem(self._model) def _make_child(self, id_): - return ScenarioItem(id_) + return ScenarioItem(self._model, id_) class ScenarioItem(GrayIfLastMixin, EditableMixin, EmptyChildMixin, FetchMoreMixin, BoldTextMixin, LeafItem): @@ -58,10 +57,10 @@ def fetch_item_type(self): def icon_code(self): return _SCENARIO_ICON - @property - def tool_tip(self): - if not self.id: + def tool_tip(self, column): + if column == 0 and not self.id: return "

Note: Scenario names longer than 20 characters might appear shortened in generated files.

" + return super().tool_tip(column) def _do_set_up(self): """Doesn't add children to the last row.""" @@ -93,11 +92,18 @@ def update_alternative_id_list(self): curr_alt_count = len(self.non_empty_children) if alt_count > curr_alt_count: added_count = alt_count - curr_alt_count - children = [ScenarioAlternativeItem() for _ in range(added_count)] + children = [ScenarioAlternativeItem(self._model) for _ in range(added_count)] self.insert_children(curr_alt_count, children) elif curr_alt_count > alt_count: removed_count = curr_alt_count - alt_count self.remove_children(alt_count, removed_count) + else: + self.model.dataChanged.emit( + self.model.index(0, 0, self.index()), self.model.index(self.row_count() - 1, 0, self.index()) + ) + + def accepts_item(self, item, db_map): + return db_map == self.db_map and item["scenario_id"] == self.id def handle_items_added(self, _db_map_data): self.update_alternative_id_list() @@ -110,7 +116,7 @@ def handle_items_updated(self, _db_map_data): def empty_child(self): """See base class.""" - return ScenarioAlternativeItem() + return ScenarioAlternativeItem(self._model) def _make_child(self, id_): """Not needed - we don't quite add children here, but rather update them in update_alternative_id_list.""" @@ -123,9 +129,10 @@ class ScenarioAlternativeItem(GrayIfLastMixin, EditableMixin, LeafItem): def item_type(self): return "scenario_alternative" - @property - def tool_tip(self): - return "

Drag and drop this item to reorder scenario alternatives

" + def tool_tip(self, column): + if column == 0: + return "

Drag and drop this item to reorder scenario alternatives

" + return super().tool_tip(column) def _make_item_data(self): return {"name": "Type scenario alternative name here...", "description": ""} diff --git a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py index 1c23f0d17..aac69c7e6 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/scenario_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,10 +9,10 @@ # 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 . ###################################################################################################################### + """Contains scenario tree model.""" import pickle - -from PySide6.QtCore import QMimeData, Qt +from PySide6.QtCore import QMimeData, Qt, QByteArray from spinetoolbox.helpers import unique_name from .tree_model_base import TreeModelBase from .scenario_item import ScenarioDBItem, ScenarioAlternativeItem, ScenarioItem @@ -22,13 +23,8 @@ class ScenarioModel(TreeModelBase): """A model to display scenarios in a tree view.""" - @staticmethod - def _make_db_item(db_map): - return ScenarioDBItem(db_map) - - @staticmethod - def _top_children(): - return [] + def _make_db_item(self, db_map): + return ScenarioDBItem(self, db_map) def supportedDropActions(self): return Qt.DropAction.CopyAction | Qt.DropAction.MoveAction @@ -66,7 +62,7 @@ def mimeData(self, indexes): db_item = item.parent_item db_key = self.db_mngr.db_map_key(db_item.db_map) scenario_data.setdefault(db_key, []).append(item.id) - mime.setData(mime_types.SCENARIO_DATA, pickle.dumps(scenario_data)) + mime.setData(mime_types.SCENARIO_DATA, QByteArray(pickle.dumps(scenario_data))) mime.setText(two_column_as_csv(scenario_indexes)) return mime alternative_indexes = [] @@ -84,18 +80,18 @@ def mimeData(self, indexes): db_item = item.parent_item.parent_item db_key = self.db_mngr.db_map_key(db_item.db_map) alternative_data.setdefault(db_key, []).append(item.alternative_id) - mime.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(alternative_data)) + mime.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(alternative_data))) mime.setText(two_column_as_csv(alternative_indexes)) return mime return None - def canDropMimeData(self, data, drop_action, row, column, parent): + def canDropMimeData(self, mime_data, drop_action, row, column, parent): if drop_action & self.supportedDropActions() == 0: return False - if not data.hasFormat(mime_types.ALTERNATIVE_DATA): + if not mime_data.hasFormat(mime_types.ALTERNATIVE_DATA): return False try: - payload = pickle.loads(data.data(mime_types.ALTERNATIVE_DATA)) + payload = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()) except pickle.UnpicklingError: return False if not isinstance(payload, dict): @@ -115,12 +111,12 @@ def canDropMimeData(self, data, drop_action, row, column, parent): db_item = self.db_item(parent_item) if db_map != db_item.db_map: return False - if data.hasFormat("application/vnd.spinetoolbox.scenario-alternative"): + if mime_data.hasFormat("application/vnd.spinetoolbox.scenario-alternative"): # Check that reordering only happens within the same scenario return False return True - def dropMimeData(self, data, drop_action, row, column, parent): + def dropMimeData(self, mime_data, drop_action, row, column, parent): # This function expects that data has be verified by canDropMimeData() already. scenario_item = self.item_from_index(parent) if not isinstance(scenario_item, ScenarioItem): @@ -128,15 +124,7 @@ def dropMimeData(self, data, drop_action, row, column, parent): # on a wrong tree item (bug in Qt or canDropMimeData()?). # In those cases the type of scen_item is StandardTreeItem or ScenarioRootItem. return False - old_alternative_id_list = list(scenario_item.alternative_id_list) - if row == -1: - row = len(old_alternative_id_list) - db_map_key, alternative_ids = pickle.loads(data.data(mime_types.ALTERNATIVE_DATA)).popitem() - alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in alternative_ids] - alternative_id_list += alternative_ids - alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in alternative_ids] - db_item = {"id": scenario_item.id, "alternative_id_list": alternative_id_list} - self.db_mngr.set_scenario_alternatives({scenario_item.db_map: [db_item]}) + self.paste_alternative_mime_data(mime_data, row, scenario_item) return True def paste_alternative_mime_data(self, mime_data, row, scenario_item): @@ -150,16 +138,21 @@ def paste_alternative_mime_data(self, mime_data, row, scenario_item): old_alternative_id_list = list(scenario_item.alternative_id_list) if row == -1: row = len(old_alternative_id_list) - data_to_add = {} - for db_map_key, alternative_ids in pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA)).items(): + new_alternative_ids = [] + for db_map_key, alternative_names in pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()).items(): target_db_map = self.db_mngr.db_map_from_key(db_map_key) if target_db_map != scenario_item.db_map: continue - alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in alternative_ids] - alternative_id_list += alternative_ids - alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in alternative_ids] - data_to_add[target_db_map] = [{"id": scenario_item.id, "alternative_id_list": alternative_id_list}] - self.db_mngr.set_scenario_alternatives(data_to_add) + for name in alternative_names: + if isinstance(name, str): + new_alternative_ids.append(scenario_item.db_map.get_alternative_item(name=name)["id"]) + else: # When rearranging alternatives in a scenario, the id is given straight + new_alternative_ids.append(name) + alternative_id_list = [id_ for id_ in old_alternative_id_list[:row] if id_ not in new_alternative_ids] + alternative_id_list += new_alternative_ids + alternative_id_list += [id_ for id_ in old_alternative_id_list[row:] if id_ not in new_alternative_ids] + db_item = {"id": scenario_item.id, "alternative_id_list": alternative_id_list} + self.db_mngr.set_scenario_alternatives({scenario_item.db_map: [db_item]}) def paste_scenario_mime_data(self, mime_data, db_item): """Adds scenarios and their alternatives from MIME data to the model. @@ -171,38 +164,36 @@ def paste_scenario_mime_data(self, mime_data, db_item): scenarios_to_add = [] alternatives_to_add = [] alternative_names_by_scenario = {} - existing_scenarios = {i.name for i in self.db_mngr.get_items(db_item.db_map, "scenario", only_visible=False)} - existing_alternatives = { - i.name for i in self.db_mngr.get_items(db_item.db_map, "alternative", only_visible=False) - } - for db_map_key, scenario_ids in pickle.loads(mime_data.data(mime_types.SCENARIO_DATA)).items(): + existing_scenarios = {i["name"] for i in self.db_mngr.get_items(db_item.db_map, "scenario")} + existing_alternatives = {i["name"] for i in self.db_mngr.get_items(db_item.db_map, "alternative")} + for db_map_key, scenario_names in pickle.loads(mime_data.data(mime_types.SCENARIO_DATA).data()).items(): db_map = self.db_mngr.db_map_from_key(db_map_key) if db_map is db_item.db_map: continue - for id_ in scenario_ids: - scenario_data = self.db_mngr.get_item(db_map, "scenario", id_, only_visible=False) - if scenario_data.name in existing_scenarios: + for name in scenario_names: + scenario_data = db_map.get_scenario_item(name=name) + if scenario_data["name"] in existing_scenarios: continue - alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, id_, only_visible=False) + alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, scenario_data["id"]) for alternative_id in alternative_id_list: - alternative_db_item = self.db_mngr.get_item( - db_map, "alternative", alternative_id, only_visible=False + alternative_db_item = self.db_mngr.get_item(db_map, "alternative", alternative_id) + alternative_names_by_scenario.setdefault(scenario_data["name"], []).append( + alternative_db_item["name"] ) - alternative_names_by_scenario.setdefault(scenario_data.name, []).append(alternative_db_item.name) - if alternative_db_item.name in existing_alternatives: + if alternative_db_item["name"] in existing_alternatives: continue alternatives_to_add.append( - {"name": alternative_db_item.name, "description": alternative_db_item.description} + {"name": alternative_db_item["name"], "description": alternative_db_item["description"]} ) - scenarios_to_add.append({"name": scenario_data.name, "description": scenario_data.description}) + scenarios_to_add.append({"name": scenario_data["name"], "description": scenario_data["description"]}) if scenarios_to_add: if alternatives_to_add: self.db_mngr.add_alternatives({db_item.db_map: alternatives_to_add}) self.db_mngr.add_scenarios({db_item.db_map: scenarios_to_add}) - alternatives = self.db_mngr.get_items(db_item.db_map, "alternative", only_visible=False) - alternative_id_by_name = {i.name: i.id for i in alternatives} + alternatives = self.db_mngr.get_items(db_item.db_map, "alternative") + alternative_id_by_name = {i["name"]: i["id"] for i in alternatives} scenarios = self.db_mngr.get_items(db_item.db_map, "scenario") - scenario_id_by_name = {i.name: i.id for i in scenarios} + scenario_id_by_name = {i["name"]: i["id"] for i in scenarios} scenario_alternative_id_lists = [] for scenario_name, alternative_name_list in alternative_names_by_scenario.items(): alternative_id_list = [alternative_id_by_name[name] for name in alternative_name_list] @@ -218,15 +209,13 @@ def duplicate_scenario(self, scenario_item): scenario_item (ScenarioItem): scenario item to duplicate """ db_map = scenario_item.db_map - existing_names = {i.name for i in self.db_mngr.get_items(db_map, "scenario", only_visible=False)} - name = unique_name(scenario_item.item_data.name, existing_names) - self.db_mngr.add_scenarios({db_map: [{"name": name, "description": scenario_item.item_data.description}]}) - alternative_id_list = self.db_mngr.get_scenario_alternative_id_list( - db_map, scenario_item.id, only_visible=False - ) - for item in self.db_mngr.get_items(db_map, "scenario", only_visible=False): - if item.name == name: + existing_names = {i["name"] for i in self.db_mngr.get_items(db_map, "scenario")} + name = unique_name(scenario_item.item_data["name"], existing_names) + self.db_mngr.add_scenarios({db_map: [{"name": name, "description": scenario_item.item_data["description"]}]}) + alternative_id_list = self.db_mngr.get_scenario_alternative_id_list(db_map, scenario_item.id) + for item in self.db_mngr.get_items(db_map, "scenario"): + if item["name"] == name: self.db_mngr.set_scenario_alternatives( - {db_map: [{"id": item.id, "alternative_id_list": alternative_id_list}]} + {db_map: [{"id": item["id"], "alternative_id_list": alternative_id_list}]} ) break diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py b/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py new file mode 100644 index 000000000..b033e0516 --- /dev/null +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_and_empty_model_mixins.py @@ -0,0 +1,76 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Miscellaneous mixins for parameter models.""" + +from spinedb_api.parameter_value import split_value_and_type + + +class ConvertToDBMixin: + """Base class for all mixins that convert model items (name-based) into database items (id-based).""" + + # pylint: disable=no-self-use + def _convert_to_db(self, item): + """Returns a db item (id-based) from the given model item (name-based). + + Args: + item (dict): the model item + + Returns: + dict: the db item + list: error log + """ + item = item.copy() + for field, real_field in self.field_map.items(): + if field in item: + item[real_field] = item.pop(field) + return item.copy(), [] + + +class SplitValueAndTypeMixin(ConvertToDBMixin): + def _convert_to_db(self, item): + item, err = super()._convert_to_db(item) + value_field, type_field = { + "parameter_value": ("value", "type"), + "parameter_definition": ("default_value", "default_type"), + }[self.item_type] + if value_field in item: + value, value_type = split_value_and_type(item[value_field]) + item[value_field] = value + item[type_field] = value_type + return item, err + + +class MakeEntityOnTheFlyMixin(ConvertToDBMixin): + """Makes relationships on the fly.""" + + @staticmethod + def _make_entity_on_the_fly(item, db_map): + """Returns a database entity item (id-based) from the given model parameter_value item (name-based). + + Args: + item (dict): the model parameter_value item + db_map (DiffDatabaseMapping): the database where the resulting item belongs + + Returns: + dict: the db entity item + list: error log + """ + entity_class_name = item.get("entity_class_name") + entity_class = db_map.get_item("entity_class", name=entity_class_name) + if not entity_class: + return None, [f"Unknown entity_class {entity_class_name}"] if entity_class_name else [] + entity_byname = item.get("entity_byname") + if not entity_byname: + return None, [] + item = {"entity_class_name": entity_class_name, "entity_byname": entity_byname} + return None if db_map.get_item("entity", **item) else item, [] diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py new file mode 100644 index 000000000..47c9d5f48 --- /dev/null +++ b/spinetoolbox/spine_db_editor/mvcmodels/single_models.py @@ -0,0 +1,417 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Single models for parameter definitions and values (as 'for a single entity').""" +from PySide6.QtCore import Qt +from spinetoolbox.helpers import DB_ITEM_SEPARATOR, plain_to_rich +from ...mvcmodels.minimal_table_model import MinimalTableModel +from ..mvcmodels.single_and_empty_model_mixins import SplitValueAndTypeMixin, MakeEntityOnTheFlyMixin +from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE +from .colors import FIXED_FIELD_COLOR + + +class HalfSortedTableModel(MinimalTableModel): + def reset_model(self, main_data=None): + """Reset model.""" + if main_data is None: + main_data = [] + self.beginResetModel() + self._main_data = sorted(main_data, key=self._sort_key) + self.endResetModel() + + def add_rows(self, data): + data = [item for item in data if item not in self._main_data] + if not data: + return + self.beginResetModel() + self._main_data += data + self._main_data.sort(key=self._sort_key) + self.endResetModel() + + def _sort_key(self, element): + return element + + +class SingleModelBase(HalfSortedTableModel): + """Base class for all single models that go in a CompoundModelBase subclass.""" + + def __init__(self, parent, db_map, entity_class_id, committed, lazy=False): + """ + Args: + parent (CompoundModelBase): the parent model + db_map (DatabaseMapping) + entity_class_id (int) + committed (bool) + """ + super().__init__(parent=parent, header=parent.header, lazy=lazy) + self.db_mngr = parent.db_mngr + self.db_map = db_map + self.entity_class_id = entity_class_id + self._auto_filter = {} # Maps field to accepted ids for that field + self.committed = committed + + def __lt__(self, other): + if self.entity_class_name == other.entity_class_name: + return self.db_map.codename < other.db_map.codename + return self.entity_class_name < other.entity_class_name + + @property + def item_type(self): + """The DB item type, required by the data method.""" + raise NotImplementedError() + + @property + def field_map(self): + return self._parent.field_map + + def update_items_in_db(self, items): + """Update items in db. Required by batch_set_data""" + items_to_upd = [] + error_log = [] + for item in items: + item_to_upd, errors = self._convert_to_db(item) + if tuple(item_to_upd.keys()) != ("id",): + items_to_upd.append(item_to_upd) + if errors: + error_log += errors + if items_to_upd: + self._do_update_items_in_db({self.db_map: items_to_upd}) + if error_log: + self.db_mngr.error_msg.emit({self.db_map: error_log}) + + @property + def _references(self): + raise NotImplementedError() + + @property + def entity_class_name(self): + return self.db_mngr.get_item(self.db_map, "entity_class", self.entity_class_id)["name"] + + @property + def dimension_id_list(self): + return self.db_mngr.get_item(self.db_map, "entity_class", self.entity_class_id)["dimension_id_list"] + + @property + def fixed_fields(self): + return ["entity_class_name", "database"] + + @property + def group_fields(self): + return ["entity_byname"] + + @property + def can_be_filtered(self): + return True + + def _mapped_field(self, field): + return self.field_map.get(field, field) + + def item_id(self, row): + """Returns parameter id for row. + + Args: + row (int): row index + + Returns: + int: parameter id + """ + return self._main_data[row] + + def item_ids(self): + """Returns model's parameter ids. + + Returns: + set of int: ids + """ + return set(self._main_data) + + def db_item(self, index): + return self._db_item(index.row()) + + def _db_item(self, row): + id_ = self._main_data[row] + return self.db_item_from_id(id_) + + def db_item_from_id(self, id_): + return self.db_mngr.get_item(self.db_map, self.item_type, id_) + + def db_items(self): + return [self._db_item(row) for row in range(self.rowCount())] + + def flags(self, index): + """Make fixed indexes non-editable.""" + flags = super().flags(index) + if self.header[index.column()] in self.fixed_fields: + return flags & ~Qt.ItemIsEditable + return flags + + def _filter_accepts_row(self, row): + item = self.db_mngr.get_item(self.db_map, self.item_type, self._main_data[row]) + return self.filter_accepts_item(item) + + def filter_accepts_item(self, item): + return self._auto_filter_accepts_item(item) + + def set_auto_filter(self, field, values): + if values == self._auto_filter.get(field, set()): + return False + self._auto_filter[field] = values + return True + + def _auto_filter_accepts_item(self, item): + """Returns the result of the auto filter.""" + if self._auto_filter is None: + return False + for field, values in self._auto_filter.items(): + if values and item.get(field) not in values: + return False + return True + + def accepted_rows(self): + """Yields accepted rows, for convenience.""" + for row in range(self.rowCount()): + if self._filter_accepts_row(row): + yield row + + def _get_ref(self, db_item, field): + """Returns the item referred by the given field.""" + ref = self._references.get(field) + if ref is None: + return {} + src_id_key, ref_type = ref + ref_if = db_item.get(src_id_key) + return self.db_mngr.get_item(self.db_map, ref_type, ref_if) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + field = self.header[index.column()] + if role == Qt.ItemDataRole.BackgroundRole and field in self.fixed_fields: + return FIXED_FIELD_COLOR + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole): + if field == "database": + return self.db_map.codename + id_ = self._main_data[index.row()] + item = self.db_mngr.get_item(self.db_map, self.item_type, id_) + if role == Qt.ItemDataRole.ToolTipRole: + description = self._get_ref(item, field).get("description") + if description: + return plain_to_rich(description) + mapped_field = self._mapped_field(field) + data = item.get(mapped_field) + if data and field in self.group_fields: + data = DB_ITEM_SEPARATOR.join(data) + return data + if role == Qt.ItemDataRole.DecorationRole and field == "entity_class_name": + return self.db_mngr.entity_class_icon(self.db_map, self.entity_class_id) + if role == DB_MAP_ROLE: + return self.db_map + return super().data(index, role) + + def batch_set_data(self, indexes, data): + """Sets data for indexes in batch. + Sets data directly in database using db mngr. If successful, updated data will be + automatically seen by the data method. + """ + + def split_value(value, column): + if self.header[column] in self.group_fields: + return tuple(value.split(DB_ITEM_SEPARATOR)) + return value + + if not indexes or not data: + return False + row_data = {} + for index, value in zip(indexes, data): + row_data.setdefault(index.row(), {})[self.header[index.column()]] = split_value(value, index.column()) + items = [dict(id=self._main_data[row], **data) for row, data in row_data.items()] + self.update_items_in_db(items) + return True + + +class FilterEntityAlternativeMixin: + """Provides the interface to filter by entity and alternative.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._filter_alternative_ids = set() + self._filter_entity_ids = set() + + def set_filter_entity_ids(self, db_map_class_entity_ids): + # Don't accept entity id filters from entities that don't belong in this model + filter_entity_ids = set().union( + *( + ent_ids + for (db_map, class_id), ent_ids in db_map_class_entity_ids.items() + if db_map == self.db_map and (class_id == self.entity_class_id or class_id in self.dimension_id_list) + ) + ) + if self._filter_entity_ids == filter_entity_ids: + return False + self._filter_entity_ids = filter_entity_ids + return True + + def set_filter_alternative_ids(self, db_map_alternative_ids): + alternative_ids = db_map_alternative_ids.get(self.db_map, set()) + if self._filter_alternative_ids == alternative_ids: + return False + self._filter_alternative_ids = alternative_ids + return True + + def filter_accepts_item(self, item): + """Reimplemented to also account for the entity and alternative filter.""" + return ( + super().filter_accepts_item(item) + and self._entity_filter_accepts_item(item) + and self._alternative_filter_accepts_item(item) + ) + + def _entity_filter_accepts_item(self, item): + """Returns the result of the entity filter.""" + if not self._filter_entity_ids: # If no entities are selected, only entity classes + return True + entity_id = item[self._mapped_field("entity_id")] + return entity_id in self._filter_entity_ids or bool(set(item["element_id_list"]) & self._filter_entity_ids) + + def _alternative_filter_accepts_item(self, item): + """Returns the result of the alternative filter.""" + if not self._filter_alternative_ids: + return True + alternative_id = item.get("alternative_id") + return alternative_id is None or alternative_id in self._filter_alternative_ids + + +class ParameterMixin: + """Provides the data method for parameter values and definitions.""" + + @property + def value_field(self): + return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type] + + @property + def parameter_definition_id_key(self): + return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type] + + @property + def _references(self): + return { + "entity_class_name": ("entity_class_id", "entity_class"), + "entity_byname": ("entity_id", "entity"), + "parameter_name": (self.parameter_definition_id_key, "parameter_definition"), + "value_list_name": ("value_list_id", "parameter_value_list"), + "description": ("id", "parameter_definition"), + "value": ("id", "parameter_value"), + "default_value": ("id", "parameter_definition"), + "database": ("database", None), + "alternative_name": ("alternative_id", "alternative"), + } + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + """Gets the id and database for the row, and reads data from the db manager + using the item_type property. + Paint the object_class icon next to the name. + Also paint background of fixed indexes gray and apply custom format to JSON fields.""" + field = self.header[index.column()] + # Display, edit, tool tip, alignment role of 'value fields' + if field == self.value_field and role in ( + Qt.ItemDataRole.DisplayRole, + Qt.ItemDataRole.EditRole, + Qt.ItemDataRole.ToolTipRole, + Qt.TextAlignmentRole, + PARSED_ROLE, + ): + id_ = self._main_data[index.row()] + item = self.db_mngr.get_item(self.db_map, self.item_type, id_) + return self.db_mngr.get_value(self.db_map, item, role) + return super().data(index, role) + + +class EntityMixin: + def update_items_in_db(self, items): + """Overriden to create entities on the fly first.""" + for item in items: + item["entity_class_name"] = self.entity_class_name + entities = [] + error_log = [] + for item in items: + entity, errors = self._make_entity_on_the_fly(item, self.db_map) + if entity: + entities.append(entity) + if errors: + error_log.extend(errors) + if entities: + self.db_mngr.add_entities({self.db_map: entities}) + if error_log: + self.db_mngr.error_msg.emit({self.db_map: error_log}) + super().update_items_in_db(items) + + def _do_update_items_in_db(self, db_map_data): + raise NotImplementedError() + + +class SingleParameterDefinitionModel(SplitValueAndTypeMixin, ParameterMixin, SingleModelBase): + """A parameter_definition model for a single entity_class.""" + + @property + def item_type(self): + return "parameter_definition" + + def _sort_key(self, element): + item = self.db_item_from_id(element) + return item.get("name", "") + + def _do_update_items_in_db(self, db_map_data): + self.db_mngr.update_parameter_definitions(db_map_data) + + +class SingleParameterValueModel( + MakeEntityOnTheFlyMixin, + SplitValueAndTypeMixin, + ParameterMixin, + EntityMixin, + FilterEntityAlternativeMixin, + SingleModelBase, +): + """A parameter_value model for a single entity_class.""" + + @property + def item_type(self): + return "parameter_value" + + def _sort_key(self, element): + item = self.db_item_from_id(element) + return (item.get("entity_byname", ()), item.get("parameter_name", ""), item.get("alternative_name", "")) + + def _do_update_items_in_db(self, db_map_data): + self.db_mngr.update_parameter_values(db_map_data) + + +class SingleEntityAlternativeModel(MakeEntityOnTheFlyMixin, EntityMixin, FilterEntityAlternativeMixin, SingleModelBase): + """An entity_alternative model for a single entity_class.""" + + @property + def item_type(self): + return "entity_alternative" + + def _sort_key(self, element): + item = self.db_item_from_id(element) + return (item.get("entity_byname", ()), item.get("alternative_name", "")) + + @property + def _references(self): + return { + "entity_class_name": ("entity_class_id", "entity_class"), + "entity_byname": ("entity_id", "entity"), + "alternative_name": ("alternative_id", "alternative"), + "database": ("database", None), + } + + def _do_update_items_in_db(self, db_map_data): + self.db_mngr.update_entity_alternatives(db_map_data) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py b/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py deleted file mode 100644 index 1a4ce111d..000000000 --- a/spinetoolbox/spine_db_editor/mvcmodels/single_parameter_models.py +++ /dev/null @@ -1,523 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Single models for parameter definitions and values (as 'for a single entity'). -""" - -from PySide6.QtCore import Qt -from spinetoolbox.helpers import DB_ITEM_SEPARATOR -from ...mvcmodels.minimal_table_model import MinimalTableModel -from ..mvcmodels.parameter_mixins import ( - FillInParameterNameMixin, - FillInValueListIdMixin, - MakeRelationshipOnTheFlyMixin, - FillInAlternativeIdMixin, - FillInParameterDefinitionIdsMixin, - FillInEntityIdsMixin, - ImposeEntityClassIdMixin, -) -from ...mvcmodels.shared import PARSED_ROLE, DB_MAP_ROLE -from .colors import FIXED_FIELD_COLOR - - -class HalfSortedTableModel(MinimalTableModel): - def reset_model(self, main_data=None): - """Reset model.""" - if main_data is None: - main_data = list() - self.beginResetModel() - self._main_data = sorted(main_data, key=self._sort_key) - self.endResetModel() - - def add_rows(self, data): - data = [item for item in data if item not in self._main_data] - if not data: - return - self.beginResetModel() - self._main_data += data - self._main_data.sort(key=self._sort_key) - self.endResetModel() - - def _sort_key(self, element): - return element - - -class SingleParameterModel(HalfSortedTableModel): - """A parameter model for a single entity_class to go in a CompoundParameterModel. - Provides methods to associate the model to an entity_class as well as - to filter entities within the class. - """ - - def __init__(self, header, db_mngr, db_map, entity_class_id, committed, lazy=False): - """Init class. - - Args: - header (list): list of field names for the header - """ - super().__init__(header=header, lazy=lazy) - self.db_mngr = db_mngr - self.db_map = db_map - self.entity_class_id = entity_class_id - self._auto_filter = dict() # Maps field to accepted ids for that field - self.committed = committed - - def __lt__(self, other): - if self.entity_class_name == other.entity_class_name: - return self.db_map.codename < other.db_map.codename - return self.entity_class_name < other.entity_class_name - - @property - def item_type(self): - """The item type, either 'parameter_value' or 'parameter_definition', required by the data method.""" - raise NotImplementedError() - - @property - def entity_class_type(self): - """The entity_class type, either 'object_class' or 'relationship_class'.""" - raise NotImplementedError() - - @property - def entity_class_name_field(self): - return {"object_class": "object_class_name", "relationship_class": "relationship_class_name"}[ - self.entity_class_type - ] - - @property - def entity_class_name(self): - return self.db_mngr.get_item(self.db_map, self.entity_class_type, self.entity_class_id)["name"] - - @property - def entity_class_id_key(self): - return {"object_class": "object_class_id", "relationship_class": "relationship_class_id"}[ - self.entity_class_type - ] - - @property - def value_field(self): - return {"parameter_definition": "default_value", "parameter_value": "value"}[self.item_type] - - @property - def fixed_fields(self): - return { - "object_class": ["object_class_name", "database"], - "relationship_class": ["relationship_class_name", "object_class_name_list", "database"], - }[self.entity_class_type] - - @property - def group_fields(self): - return { - "object_class": {"parameter_definition": [], "parameter_value": []}, - "relationship_class": { - "parameter_definition": ["object_class_name_list"], - "parameter_value": ["object_name_list"], - }, - }[self.entity_class_type][self.item_type] - - @property - def parameter_definition_id_key(self): - return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type] - - @property - def can_be_filtered(self): - return True - - def item_id(self, row): - """Returns parameter id for row. - - Args: - row (int): row index - - Returns: - int: parameter id - """ - return self._main_data[row] - - def item_ids(self): - """Returns model's parameter ids. - - Returns: - set of int: ids - """ - return set(self._main_data) - - def db_item(self, index): - return self._db_item(index.row()) - - def _db_item(self, row): - id_ = self._main_data[row] - return self.db_item_from_id(id_) - - def db_item_from_id(self, id_): - return self.db_mngr.get_item(self.db_map, self.item_type, id_) - - def db_items(self): - return [self._db_item(row) for row in range(self.rowCount())] - - def flags(self, index): - """Make fixed indexes non-editable.""" - flags = super().flags(index) - if self.header[index.column()] in self.fixed_fields: - return flags & ~Qt.ItemIsEditable - return flags - - def get_field_item_data(self, field): - """Returns item data for given field. - - Args: - field (str): A field from the header - - Returns: - str, str - """ - return { - "object_class_name": ("object_class_id", "object_class"), - "relationship_class_name": ("relationship_class_id", "relationship_class"), - "object_class_name_list": ("relationship_class_id", "relationship_class"), - "object_name": ("object_id", "object"), - "object_name_list": ("relationship_id", "relationship"), - "parameter_name": (self.parameter_definition_id_key, "parameter_definition"), - "value_list_name": ("value_list_id", "parameter_value_list"), - "description": ("id", "parameter_definition"), - "value": ("id", "parameter_value"), - "default_value": ("id", "parameter_definition"), - "database": ("database", None), - "alternative_id": ("alternative_id", "alternative"), - }.get(field) - - def get_id_key(self, field): - field_item_data = self.get_field_item_data(field) - if field_item_data is None: - return None - return field_item_data[0] - - def get_field_item(self, field, db_item): - """Returns a db item corresponding to the given field from the table header, - or an empty dict if the field doesn't contain db items. - """ - field_item_data = self.get_field_item_data(field) - if field_item_data is None: - return {} - id_key, item_type = field_item_data - item_id = db_item.get(id_key) - return self.db_mngr.get_item(self.db_map, item_type, item_id) - - def data(self, index, role=Qt.ItemDataRole.DisplayRole): - """Gets the id and database for the row, and reads data from the db manager - using the item_type property. - Paint the object_class icon next to the name. - Also paint background of fixed indexes gray and apply custom format to JSON fields.""" - field = self.header[index.column()] - # Background role - if role == Qt.ItemDataRole.BackgroundRole and field in self.fixed_fields: - return FIXED_FIELD_COLOR - # Display, edit, tool tip, alignment role of 'json fields' - if field == self.value_field and role in ( - Qt.ItemDataRole.DisplayRole, - Qt.ItemDataRole.EditRole, - Qt.ItemDataRole.ToolTipRole, - Qt.TextAlignmentRole, - PARSED_ROLE, - ): - id_ = self._main_data[index.row()] - return self.db_mngr.get_value(self.db_map, self.item_type, id_, role) - if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole, Qt.ItemDataRole.ToolTipRole): - if field == "database": - return self.db_map.codename - id_ = self._main_data[index.row()] - item = self.db_mngr.get_item(self.db_map, self.item_type, id_) - if role == Qt.ItemDataRole.ToolTipRole: - description = self.get_field_item(field, item).get("description", None) - if description not in (None, ""): - return description - data = item.get(field) - if data and field in self.group_fields: - data = DB_ITEM_SEPARATOR.join(data) - return data - if role == Qt.ItemDataRole.DecorationRole and field == self.entity_class_name_field: - return self.db_mngr.entity_class_icon(self.db_map, self.entity_class_type, self.entity_class_id) - if role == DB_MAP_ROLE: - return self.db_map - return super().data(index, role) - - def batch_set_data(self, indexes, data): - """Sets data for indexes in batch. - Sets data directly in database using db mngr. If successful, updated data will be - automatically seen by the data method. - """ - - def split_value(value, column): - if self.header[column] in self.group_fields: - return tuple(value.split(DB_ITEM_SEPARATOR)) - return value - - if not indexes or not data: - return False - row_data = dict() - for index, value in zip(indexes, data): - row_data.setdefault(index.row(), {})[self.header[index.column()]] = split_value(value, index.column()) - items = [dict(id=self._main_data[row], **data) for row, data in row_data.items()] - self.update_items_in_db(items) - return True - - def update_items_in_db(self, items): - """Update items in db. Required by batch_set_data""" - raise NotImplementedError() - - def _filter_accepts_row(self, row): - item = self.db_mngr.get_item(self.db_map, self.item_type, self._main_data[row]) - return self.filter_accepts_item(item) - - def filter_accepts_item(self, item): - return self._auto_filter_accepts_item(item) - - def set_auto_filter(self, field, values): - if values == self._auto_filter.get(field, set()): - return False - self._auto_filter[field] = values - return True - - def _auto_filter_accepts_item(self, item): - """Returns the result of the auto filter.""" - if self._auto_filter is None: - return False - for field, values in self._auto_filter.items(): - if values and item.get(field) not in values: - return False - return True - - def accepted_rows(self): - """Yields accepted rows, for convenience.""" - for row in range(self.rowCount()): - if self._filter_accepts_row(row): - yield row - - def _get_field_item(self, field, id_): - """Returns a item from the db_mngr.get_item depending on the field. - If a field doesn't correspond to a item in the database then an empty dict is returned. - """ - header_to_id = { - "object_class_name": ("entity_class_id", "object_class"), - "relationship_class_name": ("entity_class_id", "relationship_class"), - "object_name": ("entity_id", "object"), - "object_name_list": ("entity_id", "relationship"), - "parameter_name": (self.parameter_definition_id_key, "parameter_definition"), - } - id_field_item_type = header_to_id.get(field) - if id_field_item_type is None: - return {} - id_field, item_type = id_field_item_type - data = self.db_mngr.get_item(self.db_map, self.item_type, id_) - item_id = data.get(id_field) - return self.db_mngr.get_item(self.db_map, item_type, item_id) - - -class SingleObjectParameterMixin: - """Associates a parameter model with a single object_class.""" - - @property - def entity_class_type(self): - return "object_class" - - -class SingleRelationshipParameterMixin: - """Associates a parameter model with a single relationship_class.""" - - @property - def entity_class_type(self): - return "relationship_class" - - -class SingleParameterDefinitionMixin(FillInParameterNameMixin, FillInValueListIdMixin): - """A parameter_definition model for a single entity_class.""" - - @property - def item_type(self): - return "parameter_definition" - - def _sort_key(self, element): - item = self.db_item_from_id(element) - return item["parameter_name"] - - def update_items_in_db(self, items): - """Update items in db. - - Args: - items (list): dictionary-items - """ - self.build_lookup_dictionary({self.db_map: items}) - param_defs = list() - error_log = list() - for item in items: - param_def, errors = self._convert_to_db(item, self.db_map) - if tuple(param_def.keys()) != ("id",): - param_defs.append(param_def) - if errors: - error_log += errors - if param_defs: - self.db_mngr.update_parameter_definitions({self.db_map: param_defs}) - if error_log: - self.db_mngr.error_msg.emit({self.db_map: error_log}) - - -class SingleParameterValueMixin( - FillInAlternativeIdMixin, ImposeEntityClassIdMixin, FillInParameterDefinitionIdsMixin, FillInEntityIdsMixin -): - """A parameter_value model for a single entity_class.""" - - _filter_db_map_class_entity_ids = dict() - _filter_alternative_ids = set() - _filter_entity_ids = set() - - @property - def item_type(self): - return "parameter_value" - - @property - def entity_type(self): - """Either 'object' or "relationship'.""" - raise NotImplementedError() - - @property - def entity_id_key(self): - return {"object": "object_id", "relationship": "relationship_id"}[self.entity_type] - - @property - def entity_name_key(self): - return {"object": "object_name", "relationship": "object_name_list"}[self.entity_type] - - @property - def entity_name_key_in_cache(self): - return {"object": "name", "relationship": "object_name_list"}[self.entity_type] - - def _sort_key(self, element): - item = self.db_item_from_id(element) - return tuple(item[k] for k in (self.entity_name_key, "parameter_name", "alternative_name")) - - def set_filter_entity_ids(self, db_map_class_entity_ids): - if self._filter_db_map_class_entity_ids == db_map_class_entity_ids: - return False - self._filter_db_map_class_entity_ids = db_map_class_entity_ids - self._filter_entity_ids = db_map_class_entity_ids.get((self.db_map, self.entity_class_id), set()) - return True - - def set_filter_alternative_ids(self, db_map_alternative_ids): - alternative_ids = db_map_alternative_ids.get(self.db_map, set()) - if self._filter_alternative_ids == alternative_ids: - return False - self._filter_alternative_ids = alternative_ids - return True - - def filter_accepts_item(self, item): - """Reimplemented to also account for the entity and alternative filter.""" - return ( - super().filter_accepts_item(item) - and self._entity_filter_accepts_item(item) - and self._alternative_filter_accepts_item(item) - ) - - def _entity_filter_accepts_item(self, item): - """Returns the result of the entity filter.""" - object_class_id = item["object_class_id"] - if not self._filter_db_map_class_entity_ids: - return True - try: - active_class_ids = [i[1] for i in self._filter_db_map_class_entity_ids.keys()] - except TypeError: - active_class_ids = [] - if object_class_id and object_class_id not in active_class_ids: - return True - entity_id = item["entity_id"] - return entity_id in self._filter_entity_ids - - def _alternative_filter_accepts_item(self, item): - """Returns the result of the alternative filter.""" - if not self._filter_alternative_ids: - return True - alternative_id = item["alternative_id"] - return alternative_id in self._filter_alternative_ids - - def update_items_in_db(self, items): - """Update items in db. - - Args: - items (list): dictionary-items - """ - param_vals = list() - error_log = list() - db_map_data = dict() - db_map_data[self.db_map] = items - self.build_lookup_dictionary(db_map_data) - for item in items: - param_val, errors = self._convert_to_db(item, self.db_map) - if tuple(param_val.keys()) != ("id",): - param_vals.append(param_val) - if errors: - error_log += errors - if param_vals: - self.db_mngr.update_parameter_values({self.db_map: param_vals}) - if error_log: - self.db_mngr.error_msg.emit({self.db_map: error_log}) - - -class SingleObjectParameterDefinitionModel( - SingleObjectParameterMixin, SingleParameterDefinitionMixin, SingleParameterModel -): - """An object parameter_definition model for a single object_class.""" - - -class SingleRelationshipParameterDefinitionModel( - SingleRelationshipParameterMixin, SingleParameterDefinitionMixin, SingleParameterModel -): - """A relationship parameter_definition model for a single relationship_class.""" - - -class SingleObjectParameterValueModel(SingleObjectParameterMixin, SingleParameterValueMixin, SingleParameterModel): - """An object parameter_value model for a single object_class.""" - - @property - def entity_type(self): - return "object" - - -class SingleRelationshipParameterValueModel( - SingleRelationshipParameterMixin, MakeRelationshipOnTheFlyMixin, SingleParameterValueMixin, SingleParameterModel -): - """A relationship parameter_value model for a single relationship_class.""" - - @property - def entity_type(self): - return "relationship" - - def update_items_in_db(self, items): - """Update items in db. - - Args: - items (list): dictionary-items - """ - for item in items: - item["relationship_class_name"] = self.entity_class_name - db_map_data = {self.db_map: items} - self.build_lookup_dictionaries(db_map_data) - db_map_relationships = dict() - db_map_error_log = dict() - for db_map, data in db_map_data.items(): - for item in data: - relationship, err = self._make_relationship_on_the_fly(item, db_map) - if relationship: - db_map_relationships.setdefault(db_map, []).append(relationship) - if err: - db_map_error_log.setdefault(db_map, []).extend(err) - if any(db_map_relationships.values()): - self.db_mngr.add_relationships(db_map_relationships) - if db_map_error_log: - self.db_mngr.error_msg.emit(db_map_error_log) - super().update_items_in_db(items) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py b/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py deleted file mode 100644 index 1bdc8665f..000000000 --- a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_item.py +++ /dev/null @@ -1,369 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Classes to represent tool and feature items in a tree. -""" -from PySide6.QtCore import Qt -from .tree_item_utility import GrayIfLastMixin, EditableMixin, EmptyChildRootItem, LeafItem, StandardTreeItem - -_FEATURE_ICON = "\uf5bc" # splotch -_TOOL_ICON = "\uf6e3" # hammer -_METHOD_ICON = "\uf1de" # sliders-h - - -class FeatureRootItem(EmptyChildRootItem): - """A feature root item.""" - - @property - def item_type(self): - return "feature" - - @property - def display_data(self): - return "feature" - - @property - def icon_code(self): - return _FEATURE_ICON - - def empty_child(self): - return FeatureLeafItem() - - def _make_child(self, id_): - return FeatureLeafItem(id_) - - -class ToolRootItem(EmptyChildRootItem): - """A tool root item.""" - - @property - def item_type(self): - return "tool" - - @property - def display_data(self): - return "tool" - - @property - def icon_code(self): - return _TOOL_ICON - - def empty_child(self): - return ToolLeafItem() - - def _make_child(self, id_): - return ToolLeafItem(id_) - - -class FeatureLeafItem(GrayIfLastMixin, EditableMixin, LeafItem): - """A feature leaf item.""" - - @property - def item_type(self): - return "feature" - - def _make_item_data(self): - return {"name": "Enter new feature here...", "description": ""} - - @property - def item_data(self): - if not self.id: - return self._make_item_data() - item_data = self.db_mngr.get_item(self.db_map, self.item_type, self.id) - if not item_data: - return {} - item_data["name"] = self.model.make_feature_name( - item_data["entity_class_name"], item_data["parameter_definition_name"] - ) - return item_data - - @property - def tool_tip(self): - return "

Drag this item and drop it onto a tool feature item below to create a tool feature

" - - def add_item_to_db(self, db_item): - if db_item is None: - return - self.db_mngr.add_features({self.db_map: [db_item]}) - - def update_item_in_db(self, db_item): - if db_item is None: - return - self.db_mngr.update_features({self.db_map: [db_item]}) - - def flags(self, column): - return super().flags(column) | Qt.ItemIsDragEnabled - - def _make_item_to_add(self, value): - ids = self._get_ids_from_feat_name(value) - if not ids: - return None - parameter_definition_id, parameter_value_list_id = ids - return dict( - parameter_definition_id=parameter_definition_id, - parameter_value_list_id=parameter_value_list_id, - description=self.item_data["description"], - ) - - def _make_item_to_update(self, column, value): - if column != 0: - return super()._make_item_to_update(column, value) - ids = self._get_ids_from_feat_name(value) - if not ids: - return None - parameter_definition_id, parameter_value_list_id = ids - return dict( - id=self.id, parameter_definition_id=parameter_definition_id, parameter_value_list_id=parameter_value_list_id - ) - - def _get_ids_from_feat_name(self, feature_name): - ids = self.model.get_feature_data(self.db_map, feature_name) - if ids is None: - self.model._parent.msg_error.emit( - f"

Invalid feature '{feature_name}'.

" - "

Please enter a valid combination of entity class/parameter definition.

" - ) - return None - return ids - - -class ToolLeafItem(GrayIfLastMixin, EditableMixin, LeafItem): - """A tool leaf item.""" - - @property - def item_type(self): - return "tool" - - def add_item_to_db(self, db_item): - self.db_mngr.add_tools({self.db_map: [db_item]}) - - def update_item_in_db(self, db_item): - self.db_mngr.update_tools({self.db_map: [db_item]}) - - def _do_set_up(self): - if not self.id: - return - super()._do_set_up() - self.append_children([ToolFeatureRootItem()]) - - -class ToolFeatureRootItem(EmptyChildRootItem): - """A tool_feature root item.""" - - @property - def item_type(self): - return "tool_feature" - - @property - def display_data(self): - return "tool_feature" - - @property - def tool_tip(self): - return "

Drag a feature item from above and drop it here to create a tool feature

" - - @property - def icon_code(self): - return _FEATURE_ICON - - @property - def feature_id_list(self): - return [child.id for child in self.children] - - def flags(self, column): - return super().flags(column) | Qt.ItemIsDropEnabled - - def empty_child(self): - return ToolFeatureLeafItem() - - def _make_child(self, id_): - return ToolFeatureLeafItem(id_) - - def accepts_item(self, item, db_map): - return item["tool_id"] == self.parent_item.id - - -class ToolFeatureLeafItem(GrayIfLastMixin, LeafItem): - """A tool feature leaf item.""" - - @property - def item_type(self): - return "tool_feature" - - @property - def item_data(self): - if not self.id: - return dict(name="Type tool feature name here...") - item_data = self.db_mngr.get_item(self.db_map, self.item_type, self.id) - if not item_data: - return {} - feature_data = self.db_mngr.get_item(self.db_map, "feature", item_data["feature_id"]) - item_data["name"] = self.model.make_feature_name( - feature_data["entity_class_name"], feature_data["parameter_definition_name"] - ) - return item_data - - def _do_set_up(self): - if not self.id: - return - super()._do_set_up() - self.append_children([ToolFeatureRequiredItem(), ToolFeatureMethodRootItem()]) - - def _make_item_to_add(self, value): - feature_id, parameter_value_list_id = value - return { - "tool_id": self.parent_item.parent_item.id, - "feature_id": feature_id, - "parameter_value_list_id": parameter_value_list_id, - } - - def add_item_to_db(self, db_item): - self.db_mngr.add_tool_features({self.db_map: [db_item]}) - - def update_item_in_db(self, db_item): - self.db_mngr.update_tool_features({self.db_map: [db_item]}) - - def flags(self, column): - flags = super().flags(column) - if not self.id: - flags |= Qt.ItemIsEditable - return flags - - -class ToolFeatureRequiredItem(StandardTreeItem): - """A tool feature required item.""" - - @property - def item_type(self): - return "tool_feature required" - - def flags(self, column): - flags = super().flags(column) - if column == 0: - flags |= Qt.ItemIsEditable - return flags - - def data(self, column, role=Qt.ItemDataRole.DisplayRole): - if column == 0 and role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): - if not self.parent_item.item_data: - return None - required = "yes" if self.parent_item.item_data["required"] else "no" - return "required: " + required - return super().data(column, role) - - def set_data(self, column, value, role=Qt.ItemDataRole.EditRole): - if role == Qt.ItemDataRole.EditRole and column == 0: - required = {"yes": True, "no": False}.get(value) - if required is None: - return False - db_item = {"id": self.parent_item.id, "required": required} - self.parent_item.update_item_in_db(db_item) - return True - return False - - def has_children(self): - return False - - -class ToolFeatureMethodRootItem(EmptyChildRootItem): - """A tool_feature_method root item.""" - - @property - def item_type(self): - return "tool_feature_method" - - @property - def display_data(self): - return "tool_feature_method" - - @property - def icon_code(self): - return _METHOD_ICON - - def empty_child(self): - return ToolFeatureMethodLeafItem() - - def _make_child(self, id_): - return ToolFeatureMethodLeafItem(id_) - - def accepts_item(self, item, db_map): - return item["tool_feature_id"] == self.parent_item.id - - -class ToolFeatureMethodLeafItem(GrayIfLastMixin, LeafItem): - """A tool_feature_method leaf item.""" - - @property - def item_type(self): - return "tool_feature_method" - - @property - def tool_feature_item(self): - return self.parent_item.parent_item - - @property - def item_data(self): - if not self.id: - return self._make_item_data() - item_data = self.db_mngr.get_item(self.db_map, self.item_type, self.id) - if not item_data: - return {} - item_data["name"] = self.db_mngr.get_value_list_item( - self.db_map, item_data["parameter_value_list_id"], item_data["method_index"] - ) - return item_data - - def _make_item_data(self): - return {"name": "Enter new method here...", "description": ""} - - def flags(self, column): - flags = super().flags(column) - if column == 0: - flags |= Qt.ItemIsEditable - return flags - - def _make_item_to_add(self, value): - tool_feat_item = self.tool_feature_item - tool_feature_id = tool_feat_item.id - parameter_value_list_id = tool_feat_item.item_data["parameter_value_list_id"] - method_index = self._get_method_index(parameter_value_list_id, value) - if method_index is None: - return None - return dict( - tool_feature_id=tool_feature_id, parameter_value_list_id=parameter_value_list_id, method_index=method_index - ) - - def _make_item_to_update(self, column, value): - if column != 0: - return super()._make_item_to_update(column, value) - tool_feat_item = self.tool_feature_item - parameter_value_list_id = tool_feat_item.item_data["parameter_value_list_id"] - method_index = self._get_method_index(parameter_value_list_id, value) - if method_index is None: - return None - return dict(id=self.id, method_index=method_index) - - def _get_method_index(self, parameter_value_list_id, method): - method_index = self.model.get_method_index(self.tool_feature_item.db_map, parameter_value_list_id, method) - if method_index is None: - self.model._parent.msg_error.emit( - f"

Invalid method '{method}'.

" - f"

Please enter a valid method for feature '{self.tool_feature_item.name}'.

" - ) - return None - return method_index - - def add_item_to_db(self, db_item): - self.db_mngr.add_tool_feature_methods({self.db_map: [db_item]}) - - def update_item_in_db(self, db_item): - self.db_mngr.update_tool_feature_methods({self.db_map: [db_item]}) diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py b/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py deleted file mode 100644 index 632535801..000000000 --- a/spinetoolbox/spine_db_editor/mvcmodels/tool_feature_model.py +++ /dev/null @@ -1,156 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### -""" -Models to represent tools and features in a tree. -""" -import json -from PySide6.QtCore import QMimeData, Qt -from .tree_model_base import TreeModelBase -from .tree_item_utility import StandardDBItem -from .tool_feature_item import FeatureRootItem, ToolRootItem - - -class ToolFeatureModel(TreeModelBase): - """A model to display tools and features in a tree view. - - - Args: - parent (SpineDBEditor) - db_mngr (SpineDBManager) - db_maps (iter): DiffDatabaseMapping instances - """ - - def __init__(self, parent, db_mngr, *db_maps): - """Initialize class""" - super().__init__(parent, db_mngr, *db_maps) - self._db_map_feature_data = {} - self._db_map_feature_methods = {} - - @staticmethod - def _make_db_item(db_map): - return StandardDBItem(db_map) - - @staticmethod - def _top_children(): - return [FeatureRootItem(), ToolRootItem()] - - @staticmethod - def make_feature_name(entity_class_name, parameter_definition_name): - if entity_class_name is None: - entity_class_name = "" - if parameter_definition_name is None: - parameter_definition_name = "" - return entity_class_name + "/" + parameter_definition_name - - def _begin_set_features(self, db_map): - parameter_definitions = self.db_mngr.get_items(db_map, "parameter_definition", only_visible=False) - key = lambda x: self.make_feature_name( - x.get("object_class_name") or x.get("relationship_class_name"), x["parameter_name"] - ) - self._db_map_feature_data[db_map] = { - key(x): (x["id"], x["value_list_id"]) for x in parameter_definitions if x["value_list_id"] - } - - def get_all_feature_names(self, db_map): - self._begin_set_features(db_map) - return list(self._db_map_feature_data.get(db_map, {}).keys()) - - def get_feature_data(self, db_map, feature_name): - return self._db_map_feature_data.get(db_map, {}).get(feature_name) - - def _begin_set_feature_method(self, db_map, parameter_value_list_id): - parameter_value_list = self.db_mngr.get_item( - db_map, "parameter_value_list", parameter_value_list_id, only_visible=False - ) - value_index_list = parameter_value_list["value_index_list"] - display_value_list = self.db_mngr.get_parameter_value_list( - db_map, parameter_value_list_id, role=Qt.ItemDataRole.DisplayRole, only_visible=False - ) - self._db_map_feature_methods.setdefault(db_map, {})[parameter_value_list_id] = dict( - zip(display_value_list, value_index_list) - ) - - def get_all_feature_methods(self, db_map, parameter_value_list_id): - self._begin_set_feature_method(db_map, parameter_value_list_id) - return list(self._db_map_feature_methods.get(db_map, {}).get(parameter_value_list_id, {}).keys()) - - def get_method_index(self, db_map, parameter_value_list_id, method): - return self._db_map_feature_methods.get(db_map, {}).get(parameter_value_list_id, {}).get(method) - - def supportedDropActions(self): - return Qt.CopyAction | Qt.MoveAction - - def mimeData(self, indexes): - """ - Builds a dict mapping db name to item type to a list of ids. - - Returns: - QMimeData - """ - items = {self.item_from_index(ind): None for ind in indexes} # NOTE: this avoids dupes and keeps order - d = {} - for item in items: - parent_item = item.parent_item - db_row = self.db_row(parent_item) - parent_type = parent_item.item_type - master_key = ";;".join([str(db_row), parent_type]) - d.setdefault(master_key, []).append(item.child_number()) - data = json.dumps(d) - mime = QMimeData() - mime.setText(data) - return mime - - def canDropMimeData(self, data, drop_action, row, column, parent): - if not parent.isValid(): - return False - if not data.hasText(): - return False - try: - data = json.loads(data.text()) - except ValueError: - return False - if not isinstance(data, dict): - return False - # Check that all source data comes from the same db and parent - if len(data) != 1: - return False - master_key = next(iter(data)) - db_row, parent_type = master_key.split(";;") - db_row = int(db_row) - if parent_type != "feature": - return False - # Check that target is in the same db as source - tool_item = self.item_from_index(parent) - if db_row != self.db_row(tool_item): - return False - return True - - def dropMimeData(self, data, drop_action, row, column, parent): - tool_feat_root_item = self.item_from_index(parent) - master_key, feature_rows = json.loads(data.text()).popitem() - db_row, _parent_type = master_key.split(";;") - db_row = int(db_row) - feat_root_item = self._invisible_root_item.child(db_row).child(0) - db_items = [] - for feat_row in feature_rows: - item = feat_root_item.child(feat_row) - feature_id = item.id - if feature_id in tool_feat_root_item.feature_id_list: - continue - parameter_value_list_id = item.item_data.get("parameter_value_list_id") - db_item = { - "tool_id": tool_feat_root_item.parent_item.id, - "feature_id": feature_id, - "parameter_value_list_id": parameter_value_list_id, - } - db_items.append(db_item) - self.db_mngr.add_tool_features({tool_feat_root_item.db_map: db_items}) - return True diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py index df4d0ba8d..942e46ea7 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/tree_item_utility.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -A tree model for parameter_value lists. -""" - +"""A tree model for parameter_value lists.""" from PySide6.QtCore import Qt from PySide6.QtGui import QBrush, QFont, QIcon, QGuiApplication from spinetoolbox.mvcmodels.minimal_tree_model import TreeItem -from spinetoolbox.helpers import CharIconEngine, bisect_chunks +from spinetoolbox.helpers import CharIconEngine, bisect_chunks, plain_to_tool_tip from spinetoolbox.fetch_parent import FlexibleFetchParent @@ -39,8 +37,7 @@ def display_data(self): def icon_code(self): return None - @property - def tool_tip(self): + def tool_tip(self, column): return None @property @@ -51,15 +48,15 @@ def display_icon(self): return QIcon(engine.pixmap()) def data(self, column, role=Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.ToolTipRole: + return self.tool_tip(column) if column != 0: return None if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): return self.display_data if role == Qt.ItemDataRole.DecorationRole: return self.display_icon - if role == Qt.ItemDataRole.ToolTipRole: - return self.tool_tip - return super().data(column, role) + return super().data(0, role) def set_data(self, column, value, role=Qt.ItemDataRole.DisplayRole): return False @@ -87,7 +84,7 @@ class GrayIfLastMixin: """Paints the item gray if it's the last.""" def data(self, column, role=Qt.ItemDataRole.DisplayRole): - if role == Qt.ForegroundRole and self.child_number() == self.parent_item.child_count() - 1: + if role == Qt.ForegroundRole and self.child_number() == self.parent_item.row_count() - 1: gray_color = QGuiApplication.palette().text().color() gray_color.setAlpha(128) gray_brush = QBrush(gray_color) @@ -134,15 +131,6 @@ def insert_children_sorted(self, children): return False return True - def _resort(self): - # FIXME MM Needed? - non_empty_children, empty_child = self.children[:-1], self.children[-1] - non_empty_children.sort(key=self._children_sort_key) - self.children = non_empty_children + [empty_child] - top = self.model.index_from_item(self.child(0)) - bottom = self.model.index_from_item(self.child(-1)) - self.model.dataChanged.emit(top, bottom) - class FetchMoreMixin: def __init__(self, *args, **kwargs): @@ -158,6 +146,7 @@ def __init__(self, *args, **kwargs): def tear_down(self): super().tear_down() self._natural_fetch_parent.set_obsolete(True) + self._natural_fetch_parent.deleteLater() @property def fetch_item_type(self): @@ -179,6 +168,12 @@ def fetch_more(self): def _make_child(self, id_): raise NotImplementedError() + def _do_make_child(self, id_): + child = self._created_children.get(id_) + if child is None: + child = self._created_children[id_] = self._make_child(id_) + return child + def accepts_item(self, item, db_map): return True @@ -197,8 +192,8 @@ def handle_items_added(self, db_map_data): continue ids = ids_committed if item.get("commit_id") is not None else ids_uncommitted ids.append(item["id"]) - children_committed = [self._make_child(id_) for id_ in ids_committed] - children_uncommitted = [self._make_child(id_) for id_ in ids_uncommitted] + children_committed = [self._do_make_child(id_) for id_ in ids_committed] + children_uncommitted = [self._do_make_child(id_) for id_ in ids_uncommitted] self.insert_children_sorted(children_committed) self.insert_children(len(self.non_empty_children), children_uncommitted) @@ -221,21 +216,21 @@ def handle_items_updated(self, db_map_data): self.model.dataChanged.emit(index, index) if leaf_item.children: top_left = self.model.index_from_item(leaf_item.child(0)) - bottom_right = self.model.index_from_item(leaf_item.child(-1)) + bottom_right = self.model.index_from_item(leaf_item.child(leaf_item.child_count() - 1)) self.model.dataChanged.emit(top_left, bottom_right) class StandardDBItem(SortChildrenMixin, StandardTreeItem): """An item representing a db.""" - def __init__(self, db_map): + def __init__(self, model, db_map): """Init class. - Args - db_mngr (SpineDBManager) - db_map (DiffDatabaseMapping) + Args: + model (MinimalTreeModel) + db_map (DatabaseMapping) """ - super().__init__() + super().__init__(model) self.db_map = db_map @property @@ -252,30 +247,14 @@ def data(self, column, role=Qt.ItemDataRole.DisplayRole): return self.db_map.codename -class RootItem(SortChildrenMixin, BoldTextMixin, FetchMoreMixin, StandardTreeItem): - """A root item.""" - - @property - def item_type(self): - raise NotImplementedError - - @property - def db_map(self): - return self.parent_item.db_map - - -class EmptyChildRootItem(EmptyChildMixin, RootItem): - def empty_child(self): - raise NotImplementedError - - class LeafItem(StandardTreeItem): - def __init__(self, identifier=None): + def __init__(self, model, identifier=None): """ Args: + model (MinimalTreeModel) identifier (int, optional): item's database id """ - super().__init__() + super().__init__(model) self._id = identifier def _make_item_data(self): @@ -303,6 +282,11 @@ def item_data(self): def name(self): return self.item_data["name"] + def tool_tip(self, column): + if column != 0 and (header_data := self.header_data(column)) == "description": + return plain_to_tool_tip(self.item_data.get(header_data)) + return super().tool_tip(column) + def add_item_to_db(self, db_item): raise NotImplementedError() diff --git a/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py b/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py index a3b912315..7b8f0da6b 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/tree_model_base.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,10 +9,9 @@ # 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 . ###################################################################################################################### -""" -Models to represent things in a tree. -""" -from PySide6.QtCore import Qt, QModelIndex + +"""Models to represent things in a tree.""" +from PySide6.QtCore import QObject, Qt, QModelIndex, Slot from spinetoolbox.mvcmodels.minimal_tree_model import MinimalTreeModel from .tree_item_utility import StandardTreeItem @@ -24,12 +24,13 @@ def __init__(self, db_editor, db_mngr, *db_maps): Args: db_editor (SpineDBEditor) db_mngr (SpineDBManager) - *db_maps: DiffDatabaseMapping instances + *db_maps: DatabaseMapping instances """ super().__init__(db_editor) self.db_editor = db_editor self.db_mngr = db_mngr self.db_maps = db_maps + self.destroyed.connect(self._tear_down_tree) def columnCount(self, parent=QModelIndex()): """Returns the number of columns under the given parent. Always 2. @@ -47,19 +48,14 @@ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): def build_tree(self): """Builds tree.""" self.beginResetModel() + self._invisible_root_item.tear_down_recursively() self._invisible_root_item = StandardTreeItem(self) self.endResetModel() for db_map in self.db_maps: db_item = self._make_db_item(db_map) self._invisible_root_item.append_children([db_item]) - db_item.append_children(self._top_children()) - - @staticmethod - def _make_db_item(db_map): - raise NotImplementedError() - @staticmethod - def _top_children(): + def _make_db_item(self, db_map): raise NotImplementedError() @staticmethod @@ -70,3 +66,8 @@ def db_item(item): def db_row(self, item): return self.db_item(item).child_number() + + @Slot(QObject) + def _tear_down_tree(self, obj=None): + """Tears down tree items recursively""" + self._invisible_root_item.tear_down_recursively() diff --git a/spinetoolbox/spine_db_editor/mvcmodels/utils.py b/spinetoolbox/spine_db_editor/mvcmodels/utils.py index eab5e5985..4189bfa36 100644 --- a/spinetoolbox/spine_db_editor/mvcmodels/utils.py +++ b/spinetoolbox/spine_db_editor/mvcmodels/utils.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,6 +9,7 @@ # 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 . ###################################################################################################################### + """General helper functions and classes for DB editor's models.""" import csv from io import StringIO diff --git a/spinetoolbox/spine_db_editor/scenario_generation.py b/spinetoolbox/spine_db_editor/scenario_generation.py index f9fb8a262..1b6ca8920 100644 --- a/spinetoolbox/spine_db_editor/scenario_generation.py +++ b/spinetoolbox/spine_db_editor/scenario_generation.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains functions for automatically generating scenarios from a set of alternatives. -""" +"""Contains functions for automatically generating scenarios from a set of alternatives.""" from itertools import compress, permutations diff --git a/spinetoolbox/spine_db_editor/ui/__init__.py b/spinetoolbox/spine_db_editor/ui/__init__.py index 5aad8885c..7bf75cecf 100644 --- a/spinetoolbox/spine_db_editor/ui/__init__.py +++ b/spinetoolbox/spine_db_editor/ui/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py new file mode 100644 index 000000000..cd6b79a97 --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +################################################################################ +## Form generated from reading UI file 'commit_viewer_affected_item_info.ui' +## +## Created by: Qt User Interface Compiler version 6.5.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QHeaderView, QLabel, QSizePolicy, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + +class Ui_Form(object): + def setupUi(self, Form): + if not Form.objectName(): + Form.setObjectName(u"Form") + Form.resize(400, 300) + self.verticalLayout = QVBoxLayout(Form) + self.verticalLayout.setObjectName(u"verticalLayout") + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.affected_items_table = QTableWidget(Form) + self.affected_items_table.setObjectName(u"affected_items_table") + + self.verticalLayout.addWidget(self.affected_items_table) + + self.fetch_status_label = QLabel(Form) + self.fetch_status_label.setObjectName(u"fetch_status_label") + + self.verticalLayout.addWidget(self.fetch_status_label) + + + self.retranslateUi(Form) + + QMetaObject.connectSlotsByName(Form) + # setupUi + + def retranslateUi(self, Form): + Form.setWindowTitle(QCoreApplication.translate("Form", u"Form", None)) + self.fetch_status_label.setText(QCoreApplication.translate("Form", u"TextLabel", None)) + # retranslateUi + diff --git a/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui new file mode 100644 index 000000000..fe6cbacdc --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/commit_viewer_affected_item_info.ui @@ -0,0 +1,56 @@ + + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + TextLabel + + + + + + + + diff --git a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py new file mode 100644 index 000000000..e3bdbbe7d --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +################################################################################ +## Form generated from reading UI file 'db_commit_viewer.ui' +## +## Created by: Qt User Interface Compiler version 6.5.2 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QHeaderView, + QLabel, QSizePolicy, QSplitter, QStackedWidget, + QTabWidget, QTextBrowser, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget) + +class Ui_DBCommitViewer(object): + def setupUi(self, DBCommitViewer): + if not DBCommitViewer.objectName(): + DBCommitViewer.setObjectName(u"DBCommitViewer") + DBCommitViewer.resize(716, 218) + self.horizontalLayout_2 = QHBoxLayout(DBCommitViewer) + self.horizontalLayout_2.setObjectName(u"horizontalLayout_2") + self.splitter = QSplitter(DBCommitViewer) + self.splitter.setObjectName(u"splitter") + self.splitter.setOrientation(Qt.Horizontal) + self.commit_list = QTreeWidget(self.splitter) + __qtreewidgetitem = QTreeWidgetItem() + __qtreewidgetitem.setText(0, u"1"); + self.commit_list.setHeaderItem(__qtreewidgetitem) + self.commit_list.setObjectName(u"commit_list") + self.splitter.addWidget(self.commit_list) + self.verticalFrame = QFrame(self.splitter) + self.verticalFrame.setObjectName(u"verticalFrame") + self.verticalFrame.setStyleSheet(u"QFrame {\n" +" background-color: white;\n" +"}") + self.verticalFrame.setFrameShape(QFrame.Box) + self.verticalLayout = QVBoxLayout(self.verticalFrame) + self.verticalLayout.setSpacing(3) + self.verticalLayout.setObjectName(u"verticalLayout") + self.verticalLayout.setContentsMargins(5, 5, 5, 0) + self.label = QLabel(self.verticalFrame) + self.label.setObjectName(u"label") + + self.verticalLayout.addWidget(self.label) + + self.affected_items_widget_stack = QStackedWidget(self.verticalFrame) + self.affected_items_widget_stack.setObjectName(u"affected_items_widget_stack") + self.items_page = QWidget() + self.items_page.setObjectName(u"items_page") + self.horizontalLayout_3 = QHBoxLayout(self.items_page) + self.horizontalLayout_3.setSpacing(0) + self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") + self.horizontalLayout_3.setContentsMargins(0, 0, 0, 0) + self.affected_item_tab_widget = QTabWidget(self.items_page) + self.affected_item_tab_widget.setObjectName(u"affected_item_tab_widget") + self.affected_item_tab_widget.setDocumentMode(True) + + self.horizontalLayout_3.addWidget(self.affected_item_tab_widget) + + self.affected_items_widget_stack.addWidget(self.items_page) + self.no_items_page = QWidget() + self.no_items_page.setObjectName(u"no_items_page") + self.horizontalLayout = QHBoxLayout(self.no_items_page) + self.horizontalLayout.setSpacing(0) + self.horizontalLayout.setObjectName(u"horizontalLayout") + self.horizontalLayout.setContentsMargins(0, 0, 0, 0) + self.no_affected_items_notice = QTextBrowser(self.no_items_page) + self.no_affected_items_notice.setObjectName(u"no_affected_items_notice") + self.no_affected_items_notice.setFocusPolicy(Qt.NoFocus) + self.no_affected_items_notice.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.no_affected_items_notice.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.no_affected_items_notice.setOpenLinks(False) + + self.horizontalLayout.addWidget(self.no_affected_items_notice) + + self.affected_items_widget_stack.addWidget(self.no_items_page) + self.loading_page = QWidget() + self.loading_page.setObjectName(u"loading_page") + self.horizontalLayout_4 = QHBoxLayout(self.loading_page) + self.horizontalLayout_4.setSpacing(0) + self.horizontalLayout_4.setObjectName(u"horizontalLayout_4") + self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0) + self.loading_label = QLabel(self.loading_page) + self.loading_label.setObjectName(u"loading_label") + self.loading_label.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + + self.horizontalLayout_4.addWidget(self.loading_label) + + self.affected_items_widget_stack.addWidget(self.loading_page) + self.no_commit_selected_page = QWidget() + self.no_commit_selected_page.setObjectName(u"no_commit_selected_page") + self.verticalLayout_2 = QVBoxLayout(self.no_commit_selected_page) + self.verticalLayout_2.setSpacing(0) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.label_2 = QLabel(self.no_commit_selected_page) + self.label_2.setObjectName(u"label_2") + self.label_2.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignTop) + + self.verticalLayout_2.addWidget(self.label_2) + + self.affected_items_widget_stack.addWidget(self.no_commit_selected_page) + + self.verticalLayout.addWidget(self.affected_items_widget_stack) + + self.splitter.addWidget(self.verticalFrame) + + self.horizontalLayout_2.addWidget(self.splitter) + + + self.retranslateUi(DBCommitViewer) + + self.affected_items_widget_stack.setCurrentIndex(3) + self.affected_item_tab_widget.setCurrentIndex(-1) + + + QMetaObject.connectSlotsByName(DBCommitViewer) + # setupUi + + def retranslateUi(self, DBCommitViewer): + self.label.setText(QCoreApplication.translate("DBCommitViewer", u"Affected items", None)) + self.no_affected_items_notice.setHtml(QCoreApplication.translate("DBCommitViewer", u"\n" +"\n" +"

No affected items found for selected commit.

\n" +"

Note, that it is not possible to show items that have been removed by this or a later commit.

", None)) + self.loading_label.setText(QCoreApplication.translate("DBCommitViewer", u"Loading...", None)) + self.label_2.setText(QCoreApplication.translate("DBCommitViewer", u"Select a commit from the list on the left.", None)) + pass + # retranslateUi + diff --git a/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui new file mode 100644 index 000000000..e752a981d --- /dev/null +++ b/spinetoolbox/spine_db_editor/ui/db_commit_viewer.ui @@ -0,0 +1,219 @@ + + + + DBCommitViewer + + + + 0 + 0 + 716 + 218 + + + + + + + Qt::Horizontal + + + + + 1 + + + + + + QFrame { + background-color: white; +} + + + QFrame::Box + + + + 3 + + + 5 + + + 5 + + + 5 + + + 0 + + + + + Affected items + + + + + + + 3 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + -1 + + + true + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::NoFocus + + + Qt::ScrollBarAlwaysOff + + + Qt::ScrollBarAlwaysOff + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> +<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> +p, li { white-space: pre-wrap; } +hr { height: 1px; border-width: 0; } +li.unchecked::marker { content: "\2610"; } +li.checked::marker { content: "\2612"; } +</style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">No affected items found for selected commit.</p> +<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Note, that it is not possible to show items that have been removed by this or a later commit.</p></body></html> + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Loading... + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Select a commit from the list on the left. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + + + + + + + diff --git a/spinetoolbox/spine_db_editor/ui/scenario_generator.py b/spinetoolbox/spine_db_editor/ui/scenario_generator.py index 9cd058328..44c1038b4 100644 --- a/spinetoolbox/spine_db_editor/ui/scenario_generator.py +++ b/spinetoolbox/spine_db_editor/ui/scenario_generator.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/spine_db_editor/ui/select_databases.py b/spinetoolbox/spine_db_editor/ui/select_databases.py index 620e09c29..e7aae00ab 100644 --- a/spinetoolbox/spine_db_editor/ui/select_databases.py +++ b/spinetoolbox/spine_db_editor/ui/select_databases.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.py b/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.py index 15214479e..0ecba52f4 100644 --- a/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.py +++ b/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -31,10 +32,10 @@ QSizePolicy, QSpacerItem, QVBoxLayout, QWidget) from spinetoolbox.spine_db_editor.widgets.custom_qgraphicsviews import EntityQGraphicsView -from spinetoolbox.spine_db_editor.widgets.custom_qtableview import (FrozenTableView, ItemMetadataTableView, MetadataTableView, ObjectParameterDefinitionTableView, - ObjectParameterValueTableView, PivotTableView, RelationshipParameterDefinitionTableView, RelationshipParameterValueTableView) -from spinetoolbox.spine_db_editor.widgets.custom_qtreeview import (AlternativeTreeView, ObjectTreeView, ParameterValueListTreeView, RelationshipTreeView, - ScenarioTreeView, ToolFeatureTreeView) +from spinetoolbox.spine_db_editor.widgets.custom_qtableview import (EntityAlternativeTableView, FrozenTableView, ItemMetadataTableView, MetadataTableView, + ParameterDefinitionTableView, ParameterValueTableView, PivotTableView) +from spinetoolbox.spine_db_editor.widgets.custom_qtreeview import (AlternativeTreeView, EntityTreeView, ParameterValueListTreeView, ScenarioTreeView) +from spinetoolbox.spine_db_editor.widgets.custom_qwidgets import (LegendWidget, ProgressBarWidget, TimeLineWidget) from spinetoolbox import resources_icons_rc class Ui_MainWindow(object): @@ -108,7 +109,7 @@ def setupUi(self, MainWindow): self.actionMass_remove_items.setObjectName(u"actionMass_remove_items") self.actionMass_remove_items.setEnabled(False) icon10 = QIcon() - icon10.addFile(u":/icons/menu_icons/cube_minus.svg", QSize(), QIcon.Normal, QIcon.Off) + icon10.addFile(u":/icons/menu_icons/bolt-lightning.svg", QSize(), QIcon.Normal, QIcon.Off) self.actionMass_remove_items.setIcon(icon10) self.actionExport_session = QAction(MainWindow) self.actionExport_session.setObjectName(u"actionExport_session") @@ -177,7 +178,7 @@ def setupUi(self, MainWindow): self.alternative_tree_view.setDragEnabled(True) self.alternative_tree_view.setDragDropMode(QAbstractItemView.DragOnly) self.alternative_tree_view.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.alternative_tree_view.setUniformRowHeights(True) + self.alternative_tree_view.setUniformRowHeights(False) self.verticalLayout_18.addWidget(self.alternative_tree_view) @@ -195,180 +196,88 @@ def setupUi(self, MainWindow): self.treeView_parameter_value_list.setObjectName(u"treeView_parameter_value_list") self.treeView_parameter_value_list.setEditTriggers(QAbstractItemView.AnyKeyPressed|QAbstractItemView.DoubleClicked|QAbstractItemView.EditKeyPressed) self.treeView_parameter_value_list.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.treeView_parameter_value_list.setUniformRowHeights(True) self.treeView_parameter_value_list.header().setVisible(True) self.verticalLayout.addWidget(self.treeView_parameter_value_list) self.dockWidget_parameter_value_list.setWidget(self.dockWidgetContents) MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_parameter_value_list) - self.dockWidget_relationship_parameter_value = QDockWidget(MainWindow) - self.dockWidget_relationship_parameter_value.setObjectName(u"dockWidget_relationship_parameter_value") + self.dockWidget_parameter_value = QDockWidget(MainWindow) + self.dockWidget_parameter_value.setObjectName(u"dockWidget_parameter_value") self.dockWidgetContents_2 = QWidget() self.dockWidgetContents_2.setObjectName(u"dockWidgetContents_2") self.verticalLayout_5 = QVBoxLayout(self.dockWidgetContents_2) self.verticalLayout_5.setSpacing(0) self.verticalLayout_5.setObjectName(u"verticalLayout_5") self.verticalLayout_5.setContentsMargins(0, 0, 0, 0) - self.tableView_relationship_parameter_value = RelationshipParameterValueTableView(self.dockWidgetContents_2) - self.tableView_relationship_parameter_value.setObjectName(u"tableView_relationship_parameter_value") - sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - sizePolicy1.setHorizontalStretch(2) - sizePolicy1.setVerticalStretch(0) - sizePolicy1.setHeightForWidth(self.tableView_relationship_parameter_value.sizePolicy().hasHeightForWidth()) - self.tableView_relationship_parameter_value.setSizePolicy(sizePolicy1) - self.tableView_relationship_parameter_value.setMouseTracking(True) - self.tableView_relationship_parameter_value.setContextMenuPolicy(Qt.DefaultContextMenu) - self.tableView_relationship_parameter_value.setLayoutDirection(Qt.LeftToRight) - self.tableView_relationship_parameter_value.setTabKeyNavigation(False) - self.tableView_relationship_parameter_value.setSelectionBehavior(QAbstractItemView.SelectItems) - self.tableView_relationship_parameter_value.setSortingEnabled(False) - self.tableView_relationship_parameter_value.setWordWrap(False) - self.tableView_relationship_parameter_value.horizontalHeader().setHighlightSections(False) - self.tableView_relationship_parameter_value.horizontalHeader().setStretchLastSection(True) - self.tableView_relationship_parameter_value.verticalHeader().setVisible(False) - self.tableView_relationship_parameter_value.verticalHeader().setHighlightSections(False) - - self.verticalLayout_5.addWidget(self.tableView_relationship_parameter_value) - - self.dockWidget_relationship_parameter_value.setWidget(self.dockWidgetContents_2) - MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_relationship_parameter_value) - self.dockWidget_object_parameter_value = QDockWidget(MainWindow) - self.dockWidget_object_parameter_value.setObjectName(u"dockWidget_object_parameter_value") - self.dockWidgetContents_3 = QWidget() - self.dockWidgetContents_3.setObjectName(u"dockWidgetContents_3") - self.verticalLayout_3 = QVBoxLayout(self.dockWidgetContents_3) - self.verticalLayout_3.setSpacing(0) - self.verticalLayout_3.setObjectName(u"verticalLayout_3") - self.verticalLayout_3.setContentsMargins(0, 0, 0, 0) - self.tableView_object_parameter_value = ObjectParameterValueTableView(self.dockWidgetContents_3) - self.tableView_object_parameter_value.setObjectName(u"tableView_object_parameter_value") - sizePolicy1.setHeightForWidth(self.tableView_object_parameter_value.sizePolicy().hasHeightForWidth()) - self.tableView_object_parameter_value.setSizePolicy(sizePolicy1) - font = QFont() - font.setBold(False) - self.tableView_object_parameter_value.setFont(font) - self.tableView_object_parameter_value.setMouseTracking(True) - self.tableView_object_parameter_value.setContextMenuPolicy(Qt.DefaultContextMenu) - self.tableView_object_parameter_value.setLayoutDirection(Qt.LeftToRight) - self.tableView_object_parameter_value.setTabKeyNavigation(False) - self.tableView_object_parameter_value.setSelectionBehavior(QAbstractItemView.SelectItems) - self.tableView_object_parameter_value.setSortingEnabled(False) - self.tableView_object_parameter_value.setWordWrap(False) - self.tableView_object_parameter_value.horizontalHeader().setHighlightSections(False) - self.tableView_object_parameter_value.horizontalHeader().setStretchLastSection(True) - self.tableView_object_parameter_value.verticalHeader().setVisible(False) - self.tableView_object_parameter_value.verticalHeader().setHighlightSections(False) - - self.verticalLayout_3.addWidget(self.tableView_object_parameter_value) - - self.dockWidget_object_parameter_value.setWidget(self.dockWidgetContents_3) - MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_object_parameter_value) - self.dockWidget_object_parameter_definition = QDockWidget(MainWindow) - self.dockWidget_object_parameter_definition.setObjectName(u"dockWidget_object_parameter_definition") - self.dockWidgetContents_4 = QWidget() - self.dockWidgetContents_4.setObjectName(u"dockWidgetContents_4") - self.verticalLayout_8 = QVBoxLayout(self.dockWidgetContents_4) - self.verticalLayout_8.setSpacing(0) - self.verticalLayout_8.setObjectName(u"verticalLayout_8") - self.verticalLayout_8.setContentsMargins(0, 0, 0, 0) - self.tableView_object_parameter_definition = ObjectParameterDefinitionTableView(self.dockWidgetContents_4) - self.tableView_object_parameter_definition.setObjectName(u"tableView_object_parameter_definition") - sizePolicy1.setHeightForWidth(self.tableView_object_parameter_definition.sizePolicy().hasHeightForWidth()) - self.tableView_object_parameter_definition.setSizePolicy(sizePolicy1) - self.tableView_object_parameter_definition.setContextMenuPolicy(Qt.DefaultContextMenu) - self.tableView_object_parameter_definition.setLayoutDirection(Qt.LeftToRight) - self.tableView_object_parameter_definition.setTabKeyNavigation(False) - self.tableView_object_parameter_definition.setSelectionBehavior(QAbstractItemView.SelectItems) - self.tableView_object_parameter_definition.setSortingEnabled(False) - self.tableView_object_parameter_definition.setWordWrap(False) - self.tableView_object_parameter_definition.horizontalHeader().setHighlightSections(False) - self.tableView_object_parameter_definition.horizontalHeader().setProperty("showSortIndicator", False) - self.tableView_object_parameter_definition.horizontalHeader().setStretchLastSection(True) - self.tableView_object_parameter_definition.verticalHeader().setVisible(False) - self.tableView_object_parameter_definition.verticalHeader().setHighlightSections(False) - - self.verticalLayout_8.addWidget(self.tableView_object_parameter_definition) - - self.dockWidget_object_parameter_definition.setWidget(self.dockWidgetContents_4) - MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_object_parameter_definition) - self.dockWidget_relationship_parameter_definition = QDockWidget(MainWindow) - self.dockWidget_relationship_parameter_definition.setObjectName(u"dockWidget_relationship_parameter_definition") + self.tableView_parameter_value = ParameterValueTableView(self.dockWidgetContents_2) + self.tableView_parameter_value.setObjectName(u"tableView_parameter_value") + self.tableView_parameter_value.setMouseTracking(True) + self.tableView_parameter_value.setContextMenuPolicy(Qt.DefaultContextMenu) + self.tableView_parameter_value.setLayoutDirection(Qt.LeftToRight) + self.tableView_parameter_value.setTabKeyNavigation(False) + self.tableView_parameter_value.setSelectionBehavior(QAbstractItemView.SelectItems) + self.tableView_parameter_value.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.tableView_parameter_value.setSortingEnabled(False) + self.tableView_parameter_value.setWordWrap(False) + self.tableView_parameter_value.horizontalHeader().setHighlightSections(False) + self.tableView_parameter_value.verticalHeader().setVisible(False) + self.tableView_parameter_value.verticalHeader().setHighlightSections(False) + + self.verticalLayout_5.addWidget(self.tableView_parameter_value) + + self.dockWidget_parameter_value.setWidget(self.dockWidgetContents_2) + MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_parameter_value) + self.dockWidget_parameter_definition = QDockWidget(MainWindow) + self.dockWidget_parameter_definition.setObjectName(u"dockWidget_parameter_definition") self.dockWidgetContents_5 = QWidget() self.dockWidgetContents_5.setObjectName(u"dockWidgetContents_5") self.verticalLayout_10 = QVBoxLayout(self.dockWidgetContents_5) self.verticalLayout_10.setSpacing(0) self.verticalLayout_10.setObjectName(u"verticalLayout_10") self.verticalLayout_10.setContentsMargins(0, 0, 0, 0) - self.tableView_relationship_parameter_definition = RelationshipParameterDefinitionTableView(self.dockWidgetContents_5) - self.tableView_relationship_parameter_definition.setObjectName(u"tableView_relationship_parameter_definition") - sizePolicy1.setHeightForWidth(self.tableView_relationship_parameter_definition.sizePolicy().hasHeightForWidth()) - self.tableView_relationship_parameter_definition.setSizePolicy(sizePolicy1) - self.tableView_relationship_parameter_definition.setContextMenuPolicy(Qt.DefaultContextMenu) - self.tableView_relationship_parameter_definition.setLayoutDirection(Qt.LeftToRight) - self.tableView_relationship_parameter_definition.setTabKeyNavigation(False) - self.tableView_relationship_parameter_definition.setSelectionBehavior(QAbstractItemView.SelectItems) - self.tableView_relationship_parameter_definition.setSortingEnabled(False) - self.tableView_relationship_parameter_definition.setWordWrap(False) - self.tableView_relationship_parameter_definition.horizontalHeader().setHighlightSections(False) - self.tableView_relationship_parameter_definition.horizontalHeader().setStretchLastSection(True) - self.tableView_relationship_parameter_definition.verticalHeader().setVisible(False) - self.tableView_relationship_parameter_definition.verticalHeader().setHighlightSections(False) - - self.verticalLayout_10.addWidget(self.tableView_relationship_parameter_definition) - - self.dockWidget_relationship_parameter_definition.setWidget(self.dockWidgetContents_5) - MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_relationship_parameter_definition) - self.dockWidget_object_tree = QDockWidget(MainWindow) - self.dockWidget_object_tree.setObjectName(u"dockWidget_object_tree") - self.dockWidget_object_tree.setFeatures(QDockWidget.DockWidgetClosable|QDockWidget.DockWidgetFloatable|QDockWidget.DockWidgetMovable) - self.dockWidget_object_tree.setAllowedAreas(Qt.AllDockWidgetAreas) + self.tableView_parameter_definition = ParameterDefinitionTableView(self.dockWidgetContents_5) + self.tableView_parameter_definition.setObjectName(u"tableView_parameter_definition") + self.tableView_parameter_definition.setContextMenuPolicy(Qt.DefaultContextMenu) + self.tableView_parameter_definition.setTabKeyNavigation(False) + self.tableView_parameter_definition.setSelectionBehavior(QAbstractItemView.SelectItems) + self.tableView_parameter_definition.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.tableView_parameter_definition.setSortingEnabled(False) + self.tableView_parameter_definition.setWordWrap(False) + self.tableView_parameter_definition.horizontalHeader().setHighlightSections(False) + self.tableView_parameter_definition.verticalHeader().setVisible(False) + self.tableView_parameter_definition.verticalHeader().setHighlightSections(False) + + self.verticalLayout_10.addWidget(self.tableView_parameter_definition) + + self.dockWidget_parameter_definition.setWidget(self.dockWidgetContents_5) + MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_parameter_definition) + self.dockWidget_entity_tree = QDockWidget(MainWindow) + self.dockWidget_entity_tree.setObjectName(u"dockWidget_entity_tree") + self.dockWidget_entity_tree.setAllowedAreas(Qt.AllDockWidgetAreas) self.dockWidgetContents_6 = QWidget() self.dockWidgetContents_6.setObjectName(u"dockWidgetContents_6") self.verticalLayout_4 = QVBoxLayout(self.dockWidgetContents_6) self.verticalLayout_4.setSpacing(0) self.verticalLayout_4.setObjectName(u"verticalLayout_4") self.verticalLayout_4.setContentsMargins(0, 0, 0, 0) - self.treeView_object = ObjectTreeView(self.dockWidgetContents_6) - self.treeView_object.setObjectName(u"treeView_object") - sizePolicy2 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - sizePolicy2.setHorizontalStretch(1) - sizePolicy2.setVerticalStretch(0) - sizePolicy2.setHeightForWidth(self.treeView_object.sizePolicy().hasHeightForWidth()) - self.treeView_object.setSizePolicy(sizePolicy2) - self.treeView_object.setContextMenuPolicy(Qt.DefaultContextMenu) - self.treeView_object.setEditTriggers(QAbstractItemView.EditKeyPressed) - self.treeView_object.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.treeView_object.setSelectionBehavior(QAbstractItemView.SelectItems) - self.treeView_object.setIconSize(QSize(20, 20)) - self.treeView_object.setUniformRowHeights(True) - - self.verticalLayout_4.addWidget(self.treeView_object) - - self.dockWidget_object_tree.setWidget(self.dockWidgetContents_6) - MainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.dockWidget_object_tree) - self.dockWidget_relationship_tree = QDockWidget(MainWindow) - self.dockWidget_relationship_tree.setObjectName(u"dockWidget_relationship_tree") - self.dockWidget_relationship_tree.setAllowedAreas(Qt.AllDockWidgetAreas) - self.dockWidgetContents_7 = QWidget() - self.dockWidgetContents_7.setObjectName(u"dockWidgetContents_7") - self.verticalLayout_6 = QVBoxLayout(self.dockWidgetContents_7) - self.verticalLayout_6.setSpacing(0) - self.verticalLayout_6.setObjectName(u"verticalLayout_6") - self.verticalLayout_6.setContentsMargins(0, 0, 0, 0) - self.treeView_relationship = RelationshipTreeView(self.dockWidgetContents_7) - self.treeView_relationship.setObjectName(u"treeView_relationship") - self.treeView_relationship.setContextMenuPolicy(Qt.DefaultContextMenu) - self.treeView_relationship.setEditTriggers(QAbstractItemView.EditKeyPressed) - self.treeView_relationship.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.treeView_relationship.setSelectionBehavior(QAbstractItemView.SelectItems) - self.treeView_relationship.setIconSize(QSize(20, 20)) - self.treeView_relationship.setUniformRowHeights(True) - - self.verticalLayout_6.addWidget(self.treeView_relationship) - - self.dockWidget_relationship_tree.setWidget(self.dockWidgetContents_7) - MainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.dockWidget_relationship_tree) + self.treeView_entity = EntityTreeView(self.dockWidgetContents_6) + self.treeView_entity.setObjectName(u"treeView_entity") + sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + sizePolicy1.setHorizontalStretch(1) + sizePolicy1.setVerticalStretch(0) + sizePolicy1.setHeightForWidth(self.treeView_entity.sizePolicy().hasHeightForWidth()) + self.treeView_entity.setSizePolicy(sizePolicy1) + self.treeView_entity.setEditTriggers(QAbstractItemView.EditKeyPressed) + self.treeView_entity.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.treeView_entity.setSelectionBehavior(QAbstractItemView.SelectItems) + self.treeView_entity.setIconSize(QSize(20, 20)) + self.treeView_entity.setUniformRowHeights(False) + + self.verticalLayout_4.addWidget(self.treeView_entity) + + self.dockWidget_entity_tree.setWidget(self.dockWidgetContents_6) + MainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.dockWidget_entity_tree) self.dockWidget_entity_graph = QDockWidget(MainWindow) self.dockWidget_entity_graph.setObjectName(u"dockWidget_entity_graph") self.dockWidgetContents_8 = QWidget() @@ -379,14 +288,32 @@ def setupUi(self, MainWindow): self.verticalLayout_7.setContentsMargins(0, 0, 0, 0) self.graphicsView = EntityQGraphicsView(self.dockWidgetContents_8) self.graphicsView.setObjectName(u"graphicsView") - sizePolicy1.setHeightForWidth(self.graphicsView.sizePolicy().hasHeightForWidth()) - self.graphicsView.setSizePolicy(sizePolicy1) + sizePolicy2 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + sizePolicy2.setHorizontalStretch(2) + sizePolicy2.setVerticalStretch(0) + sizePolicy2.setHeightForWidth(self.graphicsView.sizePolicy().hasHeightForWidth()) + self.graphicsView.setSizePolicy(sizePolicy2) self.graphicsView.setMouseTracking(True) self.graphicsView.setFrameShape(QFrame.NoFrame) self.graphicsView.setDragMode(QGraphicsView.ScrollHandDrag) self.verticalLayout_7.addWidget(self.graphicsView) + self.time_line_widget = TimeLineWidget(self.dockWidgetContents_8) + self.time_line_widget.setObjectName(u"time_line_widget") + + self.verticalLayout_7.addWidget(self.time_line_widget) + + self.legend_widget = LegendWidget(self.dockWidgetContents_8) + self.legend_widget.setObjectName(u"legend_widget") + + self.verticalLayout_7.addWidget(self.legend_widget) + + self.progress_bar_widget = ProgressBarWidget(self.dockWidgetContents_8) + self.progress_bar_widget.setObjectName(u"progress_bar_widget") + + self.verticalLayout_7.addWidget(self.progress_bar_widget) + self.dockWidget_entity_graph.setWidget(self.dockWidgetContents_8) MainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.dockWidget_entity_graph) self.dockWidget_pivot_table = QDockWidget(MainWindow) @@ -401,6 +328,7 @@ def setupUi(self, MainWindow): self.pivot_table.setObjectName(u"pivot_table") self.pivot_table.setContextMenuPolicy(Qt.DefaultContextMenu) self.pivot_table.setTabKeyNavigation(False) + self.pivot_table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) self.verticalLayout_13.addWidget(self.pivot_table) @@ -450,25 +378,6 @@ def setupUi(self, MainWindow): self.dockWidget_exports.setWidget(self.dockWidgetContents_12) MainWindow.addDockWidget(Qt.BottomDockWidgetArea, self.dockWidget_exports) - self.dockWidget_tool_feature_tree = QDockWidget(MainWindow) - self.dockWidget_tool_feature_tree.setObjectName(u"dockWidget_tool_feature_tree") - self.dockWidgetContents_13 = QWidget() - self.dockWidgetContents_13.setObjectName(u"dockWidgetContents_13") - self.verticalLayout_2 = QVBoxLayout(self.dockWidgetContents_13) - self.verticalLayout_2.setSpacing(0) - self.verticalLayout_2.setObjectName(u"verticalLayout_2") - self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) - self.treeView_tool_feature = ToolFeatureTreeView(self.dockWidgetContents_13) - self.treeView_tool_feature.setObjectName(u"treeView_tool_feature") - self.treeView_tool_feature.setEditTriggers(QAbstractItemView.AnyKeyPressed|QAbstractItemView.DoubleClicked|QAbstractItemView.EditKeyPressed) - self.treeView_tool_feature.setDragEnabled(True) - self.treeView_tool_feature.setDragDropMode(QAbstractItemView.InternalMove) - self.treeView_tool_feature.setSelectionMode(QAbstractItemView.ExtendedSelection) - - self.verticalLayout_2.addWidget(self.treeView_tool_feature) - - self.dockWidget_tool_feature_tree.setWidget(self.dockWidgetContents_13) - MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_tool_feature_tree) self.metadata_dock_widget = QDockWidget(MainWindow) self.metadata_dock_widget.setObjectName(u"metadata_dock_widget") self.metadata_dock_contents = QWidget() @@ -520,12 +429,33 @@ def setupUi(self, MainWindow): self.scenario_tree_view.setDragDropMode(QAbstractItemView.DragDrop) self.scenario_tree_view.setDefaultDropAction(Qt.MoveAction) self.scenario_tree_view.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.scenario_tree_view.setUniformRowHeights(True) + self.scenario_tree_view.setUniformRowHeights(False) self.verticalLayout_12.addWidget(self.scenario_tree_view) self.scenario_dock_widget.setWidget(self.dockWidgetContents_9) MainWindow.addDockWidget(Qt.LeftDockWidgetArea, self.scenario_dock_widget) + self.dockWidget_entity_alternative = QDockWidget(MainWindow) + self.dockWidget_entity_alternative.setObjectName(u"dockWidget_entity_alternative") + self.dockWidgetContents_3 = QWidget() + self.dockWidgetContents_3.setObjectName(u"dockWidgetContents_3") + self.verticalLayout_2 = QVBoxLayout(self.dockWidgetContents_3) + self.verticalLayout_2.setSpacing(0) + self.verticalLayout_2.setObjectName(u"verticalLayout_2") + self.verticalLayout_2.setContentsMargins(0, 0, 0, 0) + self.tableView_entity_alternative = EntityAlternativeTableView(self.dockWidgetContents_3) + self.tableView_entity_alternative.setObjectName(u"tableView_entity_alternative") + self.tableView_entity_alternative.setTabKeyNavigation(False) + self.tableView_entity_alternative.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.tableView_entity_alternative.setWordWrap(False) + self.tableView_entity_alternative.horizontalHeader().setHighlightSections(False) + self.tableView_entity_alternative.verticalHeader().setVisible(False) + self.tableView_entity_alternative.verticalHeader().setHighlightSections(False) + + self.verticalLayout_2.addWidget(self.tableView_entity_alternative) + + self.dockWidget_entity_alternative.setWidget(self.dockWidgetContents_3) + MainWindow.addDockWidget(Qt.RightDockWidgetArea, self.dockWidget_entity_alternative) self.retranslateUi(MainWindow) @@ -535,10 +465,16 @@ def setupUi(self, MainWindow): def retranslateUi(self, MainWindow): MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"MainWindow", None)) self.actionCommit.setText(QCoreApplication.translate("MainWindow", u"&Commit...", None)) +#if QT_CONFIG(tooltip) + self.actionCommit.setToolTip(QCoreApplication.translate("MainWindow", u"

Commit

Ctrl+Enter

", None)) +#endif // QT_CONFIG(tooltip) #if QT_CONFIG(shortcut) self.actionCommit.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Return", None)) #endif // QT_CONFIG(shortcut) self.actionRollback.setText(QCoreApplication.translate("MainWindow", u"Roll&back", None)) +#if QT_CONFIG(tooltip) + self.actionRollback.setToolTip(QCoreApplication.translate("MainWindow", u"

Rollback

Ctrl+Backspace

", None)) +#endif // QT_CONFIG(tooltip) #if QT_CONFIG(shortcut) self.actionRollback.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+Backspace", None)) #endif // QT_CONFIG(shortcut) @@ -552,10 +488,16 @@ def retranslateUi(self, MainWindow): self.actionExport.setToolTip(QCoreApplication.translate("MainWindow", u"

Export data into file

", None)) #endif // QT_CONFIG(tooltip) self.actionCopy.setText(QCoreApplication.translate("MainWindow", u"Cop&y name(s) as text", None)) +#if QT_CONFIG(tooltip) + self.actionCopy.setToolTip(QCoreApplication.translate("MainWindow", u"

Copy name(s) as text

Ctrl+C

", None)) +#endif // QT_CONFIG(tooltip) #if QT_CONFIG(shortcut) self.actionCopy.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+C", None)) #endif // QT_CONFIG(shortcut) self.actionPaste.setText(QCoreApplication.translate("MainWindow", u"P&aste", None)) +#if QT_CONFIG(tooltip) + self.actionPaste.setToolTip(QCoreApplication.translate("MainWindow", u"

Paste

Ctrl+V

", None)) +#endif // QT_CONFIG(tooltip) #if QT_CONFIG(shortcut) self.actionPaste.setShortcut(QCoreApplication.translate("MainWindow", u"Ctrl+V", None)) #endif // QT_CONFIG(shortcut) @@ -585,7 +527,13 @@ def retranslateUi(self, MainWindow): self.actionUser_guide.setShortcut(QCoreApplication.translate("MainWindow", u"F1", None)) #endif // QT_CONFIG(shortcut) self.actionUndo.setText(QCoreApplication.translate("MainWindow", u"Un&do", None)) +#if QT_CONFIG(tooltip) + self.actionUndo.setToolTip(QCoreApplication.translate("MainWindow", u"

Undo

", None)) +#endif // QT_CONFIG(tooltip) self.actionRedo.setText(QCoreApplication.translate("MainWindow", u"&Redo", None)) +#if QT_CONFIG(tooltip) + self.actionRedo.setToolTip(QCoreApplication.translate("MainWindow", u"

Redo

", None)) +#endif // QT_CONFIG(tooltip) self.actionNew_db_file.setText(QCoreApplication.translate("MainWindow", u"&New...", None)) #if QT_CONFIG(tooltip) self.actionNew_db_file.setToolTip(QCoreApplication.translate("MainWindow", u"

New database file

", None)) @@ -599,7 +547,7 @@ def retranslateUi(self, MainWindow): self.actionAdd_db_file.setToolTip(QCoreApplication.translate("MainWindow", u"Add database file to the current view", None)) #endif // QT_CONFIG(tooltip) self.actionVacuum.setText(QCoreApplication.translate("MainWindow", u"Vacuum", None)) - self.alternative_dock_widget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Alternative tree", None)) + self.alternative_dock_widget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Alternative", None)) #if QT_CONFIG(accessibility) self.alternative_tree_view.setAccessibleName(QCoreApplication.translate("MainWindow", u"alternative tree", None)) #endif // QT_CONFIG(accessibility) @@ -607,40 +555,28 @@ def retranslateUi(self, MainWindow): #if QT_CONFIG(accessibility) self.treeView_parameter_value_list.setAccessibleName(QCoreApplication.translate("MainWindow", u"parameter value list", None)) #endif // QT_CONFIG(accessibility) - self.dockWidget_relationship_parameter_value.setWindowTitle(QCoreApplication.translate("MainWindow", u"Relationship parameter value", None)) -#if QT_CONFIG(accessibility) - self.tableView_relationship_parameter_value.setAccessibleName(QCoreApplication.translate("MainWindow", u"relationship parameter value", None)) -#endif // QT_CONFIG(accessibility) - self.dockWidget_object_parameter_value.setWindowTitle(QCoreApplication.translate("MainWindow", u"Object parameter value", None)) -#if QT_CONFIG(accessibility) - self.tableView_object_parameter_value.setAccessibleName(QCoreApplication.translate("MainWindow", u"object parameter value", None)) -#endif // QT_CONFIG(accessibility) - self.dockWidget_object_parameter_definition.setWindowTitle(QCoreApplication.translate("MainWindow", u"Object parameter definition", None)) -#if QT_CONFIG(accessibility) - self.tableView_object_parameter_definition.setAccessibleName(QCoreApplication.translate("MainWindow", u"object parameter definition", None)) -#endif // QT_CONFIG(accessibility) - self.dockWidget_relationship_parameter_definition.setWindowTitle(QCoreApplication.translate("MainWindow", u"Relationship parameter definition", None)) + self.dockWidget_parameter_value.setWindowTitle(QCoreApplication.translate("MainWindow", u"Parameter value", None)) #if QT_CONFIG(accessibility) - self.tableView_relationship_parameter_definition.setAccessibleName(QCoreApplication.translate("MainWindow", u"relationship parameter definition", None)) + self.tableView_parameter_value.setAccessibleName(QCoreApplication.translate("MainWindow", u"parameter value", None)) #endif // QT_CONFIG(accessibility) - self.dockWidget_object_tree.setWindowTitle(QCoreApplication.translate("MainWindow", u"Object tree", None)) + self.dockWidget_parameter_definition.setWindowTitle(QCoreApplication.translate("MainWindow", u"Parameter definition", None)) #if QT_CONFIG(accessibility) - self.treeView_object.setAccessibleName(QCoreApplication.translate("MainWindow", u"object tree", None)) + self.tableView_parameter_definition.setAccessibleName(QCoreApplication.translate("MainWindow", u"parameter definition", None)) #endif // QT_CONFIG(accessibility) - self.dockWidget_relationship_tree.setWindowTitle(QCoreApplication.translate("MainWindow", u"Relationship tree", None)) + self.dockWidget_entity_tree.setWindowTitle(QCoreApplication.translate("MainWindow", u"Entity tree", None)) #if QT_CONFIG(accessibility) - self.treeView_relationship.setAccessibleName(QCoreApplication.translate("MainWindow", u"relationship tree", None)) + self.treeView_entity.setAccessibleName(QCoreApplication.translate("MainWindow", u"entity tree", None)) #endif // QT_CONFIG(accessibility) self.dockWidget_entity_graph.setWindowTitle(QCoreApplication.translate("MainWindow", u"Entity graph", None)) self.dockWidget_pivot_table.setWindowTitle(QCoreApplication.translate("MainWindow", u"Pivot table", None)) self.dockWidget_frozen_table.setWindowTitle(QCoreApplication.translate("MainWindow", u"Frozen table", None)) self.dockWidget_exports.setWindowTitle(QCoreApplication.translate("MainWindow", u"Exports", None)) - self.dockWidget_tool_feature_tree.setWindowTitle(QCoreApplication.translate("MainWindow", u"Tool/Feature tree", None)) self.metadata_dock_widget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Metadata", None)) self.item_metadata_dock_widget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Item metadata", None)) self.scenario_dock_widget.setWindowTitle(QCoreApplication.translate("MainWindow", u"Scenario tree", None)) #if QT_CONFIG(accessibility) self.scenario_tree_view.setAccessibleName(QCoreApplication.translate("MainWindow", u"scenario tree", None)) #endif // QT_CONFIG(accessibility) + self.dockWidget_entity_alternative.setWindowTitle(QCoreApplication.translate("MainWindow", u"Entity alternative", None)) # retranslateUi diff --git a/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.ui b/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.ui index 73380e694..a7c13bc08 100644 --- a/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.ui +++ b/spinetoolbox/spine_db_editor/ui/spine_db_editor_window.ui @@ -2,6 +2,7 @@ \ No newline at end of file diff --git a/spinetoolbox/ui/resources/resources_icons.qrc b/spinetoolbox/ui/resources/resources_icons.qrc index a43d6b724..436cf50c6 100644 --- a/spinetoolbox/ui/resources/resources_icons.qrc +++ b/spinetoolbox/ui/resources/resources_icons.qrc @@ -1,7 +1,7 @@ - spinetoolbox_on_wht.png Spine_symbol.png + spinetoolbox_on_wht.png app.ico @@ -13,6 +13,8 @@ fontawesome5-searchterms.json + menu_icons/bolt-lightning.svg + share.svg menu_icons/server.svg menu_icons/broom.svg tractor.svg diff --git a/spinetoolbox/ui/resources/share.svg b/spinetoolbox/ui/resources/share.svg new file mode 100644 index 000000000..e7e262b44 --- /dev/null +++ b/spinetoolbox/ui/resources/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spinetoolbox/ui/select_database_items.py b/spinetoolbox/ui/select_database_items.py index 185641a28..7f3aa1e23 100644 --- a/spinetoolbox/ui/select_database_items.py +++ b/spinetoolbox/ui/select_database_items.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/ui/select_database_items_dialog.py b/spinetoolbox/ui/select_database_items_dialog.py index 4e0caaf03..39eee7a9a 100644 --- a/spinetoolbox/ui/select_database_items_dialog.py +++ b/spinetoolbox/ui/select_database_items_dialog.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/spinetoolbox/ui/settings.py b/spinetoolbox/ui/settings.py index 3e0473a03..ecfce7b3a 100644 --- a/spinetoolbox/ui/settings.py +++ b/spinetoolbox/ui/settings.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -34,7 +35,7 @@ QSpinBox, QSplitter, QStackedWidget, QToolButton, QVBoxLayout, QWidget) -from spinetoolbox.widgets.custom_qcombobox import CustomQComboBox +from spinetoolbox.widgets.custom_combobox import CustomQComboBox from spinetoolbox import resources_icons_rc class Ui_SettingsForm(object): @@ -362,9 +363,6 @@ def setupUi(self, SettingsForm): self.gridLayout_4.setObjectName(u"gridLayout_4") self.label_11 = QLabel(self.groupBox_gams) self.label_11.setObjectName(u"label_11") - font = QFont() - font.setPointSize(10) - self.label_11.setFont(font) self.gridLayout_4.addWidget(self.label_11, 1, 0, 1, 1) @@ -386,8 +384,9 @@ def setupUi(self, SettingsForm): self.groupBox_julia = QGroupBox(self.ExternalTools) self.groupBox_julia.setObjectName(u"groupBox_julia") - self.verticalLayout_16 = QVBoxLayout(self.groupBox_julia) - self.verticalLayout_16.setObjectName(u"verticalLayout_16") + self.verticalLayout_10 = QVBoxLayout(self.groupBox_julia) + self.verticalLayout_10.setObjectName(u"verticalLayout_10") + self.verticalLayout_10.setContentsMargins(-1, 9, -1, -1) self.horizontalLayout_14 = QHBoxLayout() self.horizontalLayout_14.setObjectName(u"horizontalLayout_14") self.verticalLayout = QVBoxLayout() @@ -478,14 +477,14 @@ def setupUi(self, SettingsForm): self.horizontalLayout_14.addLayout(self.verticalLayout_15) - self.verticalLayout_16.addLayout(self.horizontalLayout_14) + self.verticalLayout_10.addLayout(self.horizontalLayout_14) self.line = QFrame(self.groupBox_julia) self.line.setObjectName(u"line") self.line.setFrameShape(QFrame.HLine) self.line.setFrameShadow(QFrame.Sunken) - self.verticalLayout_16.addWidget(self.line) + self.verticalLayout_10.addWidget(self.line) self.horizontalLayout_12 = QHBoxLayout() self.horizontalLayout_12.setObjectName(u"horizontalLayout_12") @@ -504,7 +503,7 @@ def setupUi(self, SettingsForm): self.horizontalLayout_12.addWidget(self.pushButton_add_up_spine_opt) - self.verticalLayout_16.addLayout(self.horizontalLayout_12) + self.verticalLayout_10.addLayout(self.horizontalLayout_12) self.verticalLayout_13.addWidget(self.groupBox_julia) @@ -514,8 +513,11 @@ def setupUi(self, SettingsForm): sizePolicy.setHeightForWidth(self.groupBox_python.sizePolicy().hasHeightForWidth()) self.groupBox_python.setSizePolicy(sizePolicy) self.groupBox_python.setMinimumSize(QSize(0, 95)) - self.horizontalLayout_3 = QHBoxLayout(self.groupBox_python) - self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") + self.verticalLayout_16 = QVBoxLayout(self.groupBox_python) + self.verticalLayout_16.setObjectName(u"verticalLayout_16") + self.verticalLayout_16.setContentsMargins(-1, 9, -1, -1) + self.horizontalLayout_5 = QHBoxLayout() + self.horizontalLayout_5.setObjectName(u"horizontalLayout_5") self.verticalLayout_14 = QVBoxLayout() self.verticalLayout_14.setObjectName(u"verticalLayout_14") self.radioButton_use_python_basic_console = QRadioButton(self.groupBox_python) @@ -529,14 +531,14 @@ def setupUi(self, SettingsForm): self.verticalLayout_14.addWidget(self.radioButton_use_python_jupyter_console) - self.horizontalLayout_3.addLayout(self.verticalLayout_14) + self.horizontalLayout_5.addLayout(self.verticalLayout_14) self.line_2 = QFrame(self.groupBox_python) self.line_2.setObjectName(u"line_2") self.line_2.setFrameShape(QFrame.VLine) self.line_2.setFrameShadow(QFrame.Sunken) - self.horizontalLayout_3.addWidget(self.line_2) + self.horizontalLayout_5.addWidget(self.line_2) self.verticalLayout_5 = QVBoxLayout() self.verticalLayout_5.setObjectName(u"verticalLayout_5") @@ -580,7 +582,10 @@ def setupUi(self, SettingsForm): self.verticalLayout_5.addLayout(self.horizontalLayout_11) - self.horizontalLayout_3.addLayout(self.verticalLayout_5) + self.horizontalLayout_5.addLayout(self.verticalLayout_5) + + + self.verticalLayout_16.addLayout(self.horizontalLayout_5) self.verticalLayout_13.addWidget(self.groupBox_python) @@ -618,59 +623,133 @@ def setupUi(self, SettingsForm): self.SpineDBEditor.setObjectName(u"SpineDBEditor") self.verticalLayout_9 = QVBoxLayout(self.SpineDBEditor) self.verticalLayout_9.setObjectName(u"verticalLayout_9") - self.groupBox_spine_db_editor = QGroupBox(self.SpineDBEditor) - self.groupBox_spine_db_editor.setObjectName(u"groupBox_spine_db_editor") + self.groupBox_db_editor_general = QGroupBox(self.SpineDBEditor) + self.groupBox_db_editor_general.setObjectName(u"groupBox_db_editor_general") sizePolicy7 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) sizePolicy7.setHorizontalStretch(0) sizePolicy7.setVerticalStretch(0) - sizePolicy7.setHeightForWidth(self.groupBox_spine_db_editor.sizePolicy().hasHeightForWidth()) - self.groupBox_spine_db_editor.setSizePolicy(sizePolicy7) - self.verticalLayout_4 = QVBoxLayout(self.groupBox_spine_db_editor) + sizePolicy7.setHeightForWidth(self.groupBox_db_editor_general.sizePolicy().hasHeightForWidth()) + self.groupBox_db_editor_general.setSizePolicy(sizePolicy7) + self.verticalLayout_4 = QVBoxLayout(self.groupBox_db_editor_general) self.verticalLayout_4.setSpacing(6) self.verticalLayout_4.setObjectName(u"verticalLayout_4") - self.checkBox_commit_at_exit = QCheckBox(self.groupBox_spine_db_editor) + self.checkBox_commit_at_exit = QCheckBox(self.groupBox_db_editor_general) self.checkBox_commit_at_exit.setObjectName(u"checkBox_commit_at_exit") self.checkBox_commit_at_exit.setTristate(True) self.verticalLayout_4.addWidget(self.checkBox_commit_at_exit) - self.checkBox_db_editor_show_undo = QCheckBox(self.groupBox_spine_db_editor) + self.checkBox_db_editor_show_undo = QCheckBox(self.groupBox_db_editor_general) self.checkBox_db_editor_show_undo.setObjectName(u"checkBox_db_editor_show_undo") self.verticalLayout_4.addWidget(self.checkBox_db_editor_show_undo) - self.checkBox_object_tree_sticky_selection = QCheckBox(self.groupBox_spine_db_editor) - self.checkBox_object_tree_sticky_selection.setObjectName(u"checkBox_object_tree_sticky_selection") - self.verticalLayout_4.addWidget(self.checkBox_object_tree_sticky_selection) + self.verticalLayout_9.addWidget(self.groupBox_db_editor_general) - self.checkBox_relationship_items_follow = QCheckBox(self.groupBox_spine_db_editor) - self.checkBox_relationship_items_follow.setObjectName(u"checkBox_relationship_items_follow") + self.groupBox_entity_tree = QGroupBox(self.SpineDBEditor) + self.groupBox_entity_tree.setObjectName(u"groupBox_entity_tree") + self.verticalLayout_3 = QVBoxLayout(self.groupBox_entity_tree) + self.verticalLayout_3.setObjectName(u"verticalLayout_3") + self.checkBox_entity_tree_sticky_selection = QCheckBox(self.groupBox_entity_tree) + self.checkBox_entity_tree_sticky_selection.setObjectName(u"checkBox_entity_tree_sticky_selection") - self.verticalLayout_4.addWidget(self.checkBox_relationship_items_follow) + self.verticalLayout_3.addWidget(self.checkBox_entity_tree_sticky_selection) - self.checkBox_smooth_entity_graph_zoom = QCheckBox(self.groupBox_spine_db_editor) - self.checkBox_smooth_entity_graph_zoom.setObjectName(u"checkBox_smooth_entity_graph_zoom") + self.checkBox_hide_empty_classes = QCheckBox(self.groupBox_entity_tree) + self.checkBox_hide_empty_classes.setObjectName(u"checkBox_hide_empty_classes") - self.verticalLayout_4.addWidget(self.checkBox_smooth_entity_graph_zoom) + self.verticalLayout_3.addWidget(self.checkBox_hide_empty_classes) - self.checkBox_smooth_entity_graph_rotation = QCheckBox(self.groupBox_spine_db_editor) + + self.verticalLayout_9.addWidget(self.groupBox_entity_tree) + + self.groupBox_entity_graph = QGroupBox(self.SpineDBEditor) + self.groupBox_entity_graph.setObjectName(u"groupBox_entity_graph") + self.gridLayout_5 = QGridLayout(self.groupBox_entity_graph) + self.gridLayout_5.setObjectName(u"gridLayout_5") + self.checkBox_smooth_entity_graph_rotation = QCheckBox(self.groupBox_entity_graph) self.checkBox_smooth_entity_graph_rotation.setObjectName(u"checkBox_smooth_entity_graph_rotation") - self.verticalLayout_4.addWidget(self.checkBox_smooth_entity_graph_rotation) + self.gridLayout_5.addWidget(self.checkBox_smooth_entity_graph_rotation, 4, 0, 1, 1) + + self.checkBox_smooth_entity_graph_zoom = QCheckBox(self.groupBox_entity_graph) + self.checkBox_smooth_entity_graph_zoom.setObjectName(u"checkBox_smooth_entity_graph_zoom") + + self.gridLayout_5.addWidget(self.checkBox_smooth_entity_graph_zoom, 3, 0, 1, 1) + + self.spinBox_layout_algo_max_iterations = QSpinBox(self.groupBox_entity_graph) + self.spinBox_layout_algo_max_iterations.setObjectName(u"spinBox_layout_algo_max_iterations") + self.spinBox_layout_algo_max_iterations.setMinimum(1) + self.spinBox_layout_algo_max_iterations.setMaximum(100) + self.spinBox_layout_algo_max_iterations.setValue(12) + + self.gridLayout_5.addWidget(self.spinBox_layout_algo_max_iterations, 6, 1, 1, 1) + + self.label_10 = QLabel(self.groupBox_entity_graph) + self.label_10.setObjectName(u"label_10") + + self.gridLayout_5.addWidget(self.label_10, 7, 0, 1, 1) + + self.spinBox_layout_algo_spread_factor = QSpinBox(self.groupBox_entity_graph) + self.spinBox_layout_algo_spread_factor.setObjectName(u"spinBox_layout_algo_spread_factor") + self.spinBox_layout_algo_spread_factor.setMinimum(1) + self.spinBox_layout_algo_spread_factor.setMaximum(100) + self.spinBox_layout_algo_spread_factor.setValue(100) + + self.gridLayout_5.addWidget(self.spinBox_layout_algo_spread_factor, 7, 1, 1, 1) + + self.label_6 = QLabel(self.groupBox_entity_graph) + self.label_6.setObjectName(u"label_6") + sizePolicy8 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + sizePolicy8.setHorizontalStretch(2) + sizePolicy8.setVerticalStretch(0) + sizePolicy8.setHeightForWidth(self.label_6.sizePolicy().hasHeightForWidth()) + self.label_6.setSizePolicy(sizePolicy8) + + self.gridLayout_5.addWidget(self.label_6, 6, 0, 1, 1) - self.checkBox_auto_expand_objects = QCheckBox(self.groupBox_spine_db_editor) - self.checkBox_auto_expand_objects.setObjectName(u"checkBox_auto_expand_objects") + self.checkBox_auto_expand_entities = QCheckBox(self.groupBox_entity_graph) + self.checkBox_auto_expand_entities.setObjectName(u"checkBox_auto_expand_entities") - self.verticalLayout_4.addWidget(self.checkBox_auto_expand_objects) + self.gridLayout_5.addWidget(self.checkBox_auto_expand_entities, 0, 0, 1, 1) - self.checkBox_merge_dbs = QCheckBox(self.groupBox_spine_db_editor) + self.label_16 = QLabel(self.groupBox_entity_graph) + self.label_16.setObjectName(u"label_16") + + self.gridLayout_5.addWidget(self.label_16, 8, 0, 1, 1) + + self.checkBox_snap_entities = QCheckBox(self.groupBox_entity_graph) + self.checkBox_snap_entities.setObjectName(u"checkBox_snap_entities") + + self.gridLayout_5.addWidget(self.checkBox_snap_entities, 2, 0, 1, 1) + + self.checkBox_merge_dbs = QCheckBox(self.groupBox_entity_graph) self.checkBox_merge_dbs.setObjectName(u"checkBox_merge_dbs") - self.verticalLayout_4.addWidget(self.checkBox_merge_dbs) + self.gridLayout_5.addWidget(self.checkBox_merge_dbs, 1, 0, 1, 1) + + self.spinBox_layout_algo_neg_weight_exp = QSpinBox(self.groupBox_entity_graph) + self.spinBox_layout_algo_neg_weight_exp.setObjectName(u"spinBox_layout_algo_neg_weight_exp") + self.spinBox_layout_algo_neg_weight_exp.setMinimum(1) + self.spinBox_layout_algo_neg_weight_exp.setMaximum(100) + + self.gridLayout_5.addWidget(self.spinBox_layout_algo_neg_weight_exp, 8, 1, 1, 1) + + self.label_3 = QLabel(self.groupBox_entity_graph) + self.label_3.setObjectName(u"label_3") + + self.gridLayout_5.addWidget(self.label_3, 5, 0, 1, 1) + + self.spinBox_max_ent_dim_count = QSpinBox(self.groupBox_entity_graph) + self.spinBox_max_ent_dim_count.setObjectName(u"spinBox_max_ent_dim_count") + self.spinBox_max_ent_dim_count.setMinimum(2) + self.spinBox_max_ent_dim_count.setValue(5) + self.gridLayout_5.addWidget(self.spinBox_max_ent_dim_count, 5, 1, 1, 1) - self.verticalLayout_9.addWidget(self.groupBox_spine_db_editor) + + self.verticalLayout_9.addWidget(self.groupBox_entity_graph) self.verticalSpacer_9 = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) @@ -939,13 +1018,7 @@ def setupUi(self, SettingsForm): QWidget.setTabOrder(self.lineEdit_conda_path, self.toolButton_browse_conda) QWidget.setTabOrder(self.toolButton_browse_conda, self.checkBox_commit_at_exit) QWidget.setTabOrder(self.checkBox_commit_at_exit, self.checkBox_db_editor_show_undo) - QWidget.setTabOrder(self.checkBox_db_editor_show_undo, self.checkBox_object_tree_sticky_selection) - QWidget.setTabOrder(self.checkBox_object_tree_sticky_selection, self.checkBox_relationship_items_follow) - QWidget.setTabOrder(self.checkBox_relationship_items_follow, self.checkBox_smooth_entity_graph_zoom) - QWidget.setTabOrder(self.checkBox_smooth_entity_graph_zoom, self.checkBox_smooth_entity_graph_rotation) - QWidget.setTabOrder(self.checkBox_smooth_entity_graph_rotation, self.checkBox_auto_expand_objects) - QWidget.setTabOrder(self.checkBox_auto_expand_objects, self.checkBox_merge_dbs) - QWidget.setTabOrder(self.checkBox_merge_dbs, self.checkBox_save_spec_before_closing) + QWidget.setTabOrder(self.checkBox_db_editor_show_undo, self.checkBox_save_spec_before_closing) QWidget.setTabOrder(self.checkBox_save_spec_before_closing, self.checkBox_spec_show_undo) QWidget.setTabOrder(self.checkBox_spec_show_undo, self.unlimited_engine_process_radio_button) QWidget.setTabOrder(self.unlimited_engine_process_radio_button, self.automatic_engine_process_limit_radio_button) @@ -962,12 +1035,23 @@ def setupUi(self, SettingsForm): QWidget.setTabOrder(self.comboBox_security, self.lineEdit_secfolder) QWidget.setTabOrder(self.lineEdit_secfolder, self.toolButton_pick_secfolder) QWidget.setTabOrder(self.toolButton_pick_secfolder, self.listWidget) + QWidget.setTabOrder(self.listWidget, self.checkBox_entity_tree_sticky_selection) + QWidget.setTabOrder(self.checkBox_entity_tree_sticky_selection, self.checkBox_hide_empty_classes) + QWidget.setTabOrder(self.checkBox_hide_empty_classes, self.checkBox_smooth_entity_graph_rotation) + QWidget.setTabOrder(self.checkBox_smooth_entity_graph_rotation, self.checkBox_smooth_entity_graph_zoom) + QWidget.setTabOrder(self.checkBox_smooth_entity_graph_zoom, self.spinBox_layout_algo_max_iterations) + QWidget.setTabOrder(self.spinBox_layout_algo_max_iterations, self.spinBox_layout_algo_spread_factor) + QWidget.setTabOrder(self.spinBox_layout_algo_spread_factor, self.checkBox_auto_expand_entities) + QWidget.setTabOrder(self.checkBox_auto_expand_entities, self.checkBox_snap_entities) + QWidget.setTabOrder(self.checkBox_snap_entities, self.checkBox_merge_dbs) + QWidget.setTabOrder(self.checkBox_merge_dbs, self.spinBox_layout_algo_neg_weight_exp) + QWidget.setTabOrder(self.spinBox_layout_algo_neg_weight_exp, self.spinBox_max_ent_dim_count) self.retranslateUi(SettingsForm) self.listWidget.currentRowChanged.connect(self.stackedWidget.setCurrentIndex) self.listWidget.setCurrentRow(-1) - self.stackedWidget.setCurrentIndex(0) + self.stackedWidget.setCurrentIndex(2) QMetaObject.connectSlotsByName(SettingsForm) @@ -1056,25 +1140,28 @@ def retranslateUi(self, SettingsForm): self.lineEdit_gams_path.setPlaceholderText(QCoreApplication.translate("SettingsForm", u"Using system's default GAMS", None)) #if QT_CONFIG(tooltip) self.toolButton_browse_gams.setToolTip(QCoreApplication.translate("SettingsForm", u"

Pick GAMS executable using a file browser (eg. gams.exe on Windows)

", None)) +#endif // QT_CONFIG(tooltip) +#if QT_CONFIG(tooltip) + self.groupBox_julia.setToolTip(QCoreApplication.translate("SettingsForm", u"

Default settings for new Julia Tool specs. Defaults can be changed for each Tool specification separately.

", None)) #endif // QT_CONFIG(tooltip) self.groupBox_julia.setTitle(QCoreApplication.translate("SettingsForm", u"Julia", None)) #if QT_CONFIG(tooltip) - self.radioButton_use_julia_basic_console.setToolTip(QCoreApplication.translate("SettingsForm", u"

Use basic Julia REPL to execute Julia Tool specs

", None)) + self.radioButton_use_julia_basic_console.setToolTip(QCoreApplication.translate("SettingsForm", u"

Execute Julia Tool specifications in basic Julia REPL.

NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) self.radioButton_use_julia_basic_console.setText(QCoreApplication.translate("SettingsForm", u"Basic Console", None)) #if QT_CONFIG(tooltip) - self.radioButton_use_julia_jupyter_console.setToolTip(QCoreApplication.translate("SettingsForm", u"

Use Jupyter Console to execute Julia Tool specs. Select a Julia kernel spec to use this option.

", None)) + self.radioButton_use_julia_jupyter_console.setToolTip(QCoreApplication.translate("SettingsForm", u"

Use Jupyter Console to execute Julia Tool specs. Select a Julia kernel spec to use this option.

NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) self.radioButton_use_julia_jupyter_console.setText(QCoreApplication.translate("SettingsForm", u"Jupyter Console", None)) #if QT_CONFIG(tooltip) - self.lineEdit_julia_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Julia executable. Leave blank to use Julia defined in your system path.

", None)) + self.lineEdit_julia_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Julia executable for Basic Console. Leave blank to use the Julia in your system PATH env. If Julia is not in your PATH, this line edit will be empty, indicating that Julia Tool Specs won't execute.

NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) self.lineEdit_julia_path.setPlaceholderText(QCoreApplication.translate("SettingsForm", u"Using Julia executable in system path", None)) #if QT_CONFIG(tooltip) self.toolButton_browse_julia.setToolTip(QCoreApplication.translate("SettingsForm", u"

Pick Julia executable using a file browser

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.lineEdit_julia_project_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Julia environment/project directory for Julia Tool specifications. Leave blank to use the default environment.

", None)) + self.lineEdit_julia_project_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Julia environment/project directory for Julia Tool specifications. Leave blank to use the default environment.

NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) self.lineEdit_julia_project_path.setText("") self.lineEdit_julia_project_path.setPlaceholderText(QCoreApplication.translate("SettingsForm", u"Using Julia default project", None)) @@ -1083,18 +1170,24 @@ def retranslateUi(self, SettingsForm): #endif // QT_CONFIG(tooltip) self.toolButton_browse_julia_project.setText(QCoreApplication.translate("SettingsForm", u"...", None)) #if QT_CONFIG(tooltip) - self.comboBox_julia_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Select a Julia kernel spec for Jupyter Console. Open Kernel spec editor to view/add new ones.

", None)) + self.comboBox_julia_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Select a Julia kernel spec for Jupyter Console.

NOTE: This is the default setting for new Julia Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.pushButton_make_julia_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

- Selects a Julia kernel for selected Julia executable and Julia project if it already exists.

- Creates a Julia kernel for selected Julia executable and Julia project if it does not exist.

- Overwrites a Julia kernel if a Julia kernel for selected Julia executable already exists but the Julia project is different.

You can also create Julia kernels manually using the IJulia package.

", None)) + self.pushButton_make_julia_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Creates a Julia kernel for selected Julia executable and Julia project if it does not exist using the IJulia package. Selects a Julia kernel matching the selected Julia executable and Julia project if it already exists. May overwrite a Julia kernel if one for selected Julia executable already exists but the Julia project is different.

You can also create Julia kernels manually using the IJulia package.

", None)) #endif // QT_CONFIG(tooltip) self.pushButton_make_julia_kernel.setText(QCoreApplication.translate("SettingsForm", u"Make Julia Kernel", None)) +#if QT_CONFIG(tooltip) + self.pushButton_install_julia.setToolTip(QCoreApplication.translate("SettingsForm", u"

Installs the latest Julia on your system using Python's jill package

", None)) +#endif // QT_CONFIG(tooltip) self.pushButton_install_julia.setText(QCoreApplication.translate("SettingsForm", u"Install Julia", None)) +#if QT_CONFIG(tooltip) + self.pushButton_add_up_spine_opt.setToolTip(QCoreApplication.translate("SettingsForm", u"

Adds or updates SpineOpt.jl package using the Julia executable & project selected above

", None)) +#endif // QT_CONFIG(tooltip) self.pushButton_add_up_spine_opt.setText(QCoreApplication.translate("SettingsForm", u"Add/Update SpineOpt", None)) #if QT_CONFIG(tooltip) self.groupBox_python.setToolTip(QCoreApplication.translate("SettingsForm", u"

Default settings for new Python Tool specs. Defaults can be changed for each Tool specification separately.

", None)) #endif // QT_CONFIG(tooltip) - self.groupBox_python.setTitle(QCoreApplication.translate("SettingsForm", u"Python (default settings)", None)) + self.groupBox_python.setTitle(QCoreApplication.translate("SettingsForm", u"Python", None)) #if QT_CONFIG(tooltip) self.radioButton_use_python_basic_console.setToolTip(QCoreApplication.translate("SettingsForm", u"

Execute Python Tool specifications in basic Python REPL.

NOTE: This is the default setting for new Python Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) @@ -1104,17 +1197,17 @@ def retranslateUi(self, SettingsForm): #endif // QT_CONFIG(tooltip) self.radioButton_use_python_jupyter_console.setText(QCoreApplication.translate("SettingsForm", u"Jupyter Console", None)) #if QT_CONFIG(tooltip) - self.lineEdit_python_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Python interpreter for Python Console. Leave blank to use the Python that was used in launching Spine Toolbox.

NOTE: This is the default setting for new Python Tool specs. You can override this for each Tool spec separately.

", None)) + self.lineEdit_python_path.setToolTip(QCoreApplication.translate("SettingsForm", u"

Python interpreter for Basic Console. Leave blank to use the Python that was used in launching Spine Toolbox.

NOTE: This is the default setting for new Python Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) self.lineEdit_python_path.setPlaceholderText(QCoreApplication.translate("SettingsForm", u"Using current Python interpreter", None)) #if QT_CONFIG(tooltip) self.toolButton_browse_python.setToolTip(QCoreApplication.translate("SettingsForm", u"

Pick Python interpreter using a file browser

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.comboBox_python_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Select a Python kernel spec for Jupyter Console. Open Kernel spec editor to view/add new ones.

NOTE: This is the default setting for new Python Tool specs. You can override this for each Tool spec separately.

", None)) + self.comboBox_python_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Select a Python kernel spec for Jupyter Console.

NOTE: This is the default setting for new Python Tool specs. You can override this for each Tool spec separately.

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.pushButton_make_python_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

- Selects a Python kernel for selected Python interpreter if it already exists.

- Creates a new Python kernel for selected Python interpreter if it does not exist.

You can also create Python kernels manually using the ipykernel package.

", None)) + self.pushButton_make_python_kernel.setToolTip(QCoreApplication.translate("SettingsForm", u"

Creates a Python kernel for selected Python interpreter if it does not exist using the ipykernel package. Selects a Python kernel matching the selected Python interpreter if it already exists.

You can also create Python kernels manually using the ipykernel package.

", None)) #endif // QT_CONFIG(tooltip) self.pushButton_make_python_kernel.setText(QCoreApplication.translate("SettingsForm", u"Make Python Kernel", None)) self.groupBox_2.setTitle(QCoreApplication.translate("SettingsForm", u"Conda", None)) @@ -1126,27 +1219,32 @@ def retranslateUi(self, SettingsForm): self.toolButton_browse_conda.setToolTip(QCoreApplication.translate("SettingsForm", u"

Pick Conda executable using a file browser

", None)) #endif // QT_CONFIG(tooltip) self.toolButton_browse_conda.setText("") - self.groupBox_spine_db_editor.setTitle(QCoreApplication.translate("SettingsForm", u"Spine database editor", None)) + self.groupBox_db_editor_general.setTitle(QCoreApplication.translate("SettingsForm", u"General", None)) #if QT_CONFIG(tooltip) self.checkBox_commit_at_exit.setToolTip(QCoreApplication.translate("SettingsForm", u"

Unchecked: Don't commit session and don't show message box

Partially checked: Show message box (default)

Checked: Commit session and don't show message box


", None)) #endif // QT_CONFIG(tooltip) self.checkBox_commit_at_exit.setText(QCoreApplication.translate("SettingsForm", u"Commit session before closing", None)) self.checkBox_db_editor_show_undo.setText(QCoreApplication.translate("SettingsForm", u"Show undo notifications", None)) + self.groupBox_entity_tree.setTitle(QCoreApplication.translate("SettingsForm", u"Entity tree", None)) #if QT_CONFIG(tooltip) - self.checkBox_object_tree_sticky_selection.setToolTip(QCoreApplication.translate("SettingsForm", u"

Controls how selecting items in Object tree using the left mouse button works.

When unchecked [default], Single selection is enabled. Pressing the Ctrl-button down enables multiple selection.

When checked, Multiple selection is enabled. Pressing the Ctrl-button down enables single selection.

", None)) -#endif // QT_CONFIG(tooltip) - self.checkBox_object_tree_sticky_selection.setText(QCoreApplication.translate("SettingsForm", u"Sticky selection in Entity trees", None)) -#if QT_CONFIG(tooltip) - self.checkBox_relationship_items_follow.setToolTip(QCoreApplication.translate("SettingsForm", u"

When checked [default], moving Object items causes connected Relationship items to follow.

", None)) + self.checkBox_entity_tree_sticky_selection.setToolTip(QCoreApplication.translate("SettingsForm", u"

Controls how selecting items in Object tree using the left mouse button works.

When unchecked [default], Single selection is enabled. Pressing the Ctrl-button down enables multiple selection.

When checked, Multiple selection is enabled. Pressing the Ctrl-button down enables single selection.

", None)) #endif // QT_CONFIG(tooltip) - self.checkBox_relationship_items_follow.setText(QCoreApplication.translate("SettingsForm", u"Move relationships along with objects in Entity graph", None)) - self.checkBox_smooth_entity_graph_zoom.setText(QCoreApplication.translate("SettingsForm", u"Smooth Entity graph zoom", None)) - self.checkBox_smooth_entity_graph_rotation.setText(QCoreApplication.translate("SettingsForm", u"Smooth Entity graph rotation", None)) + self.checkBox_entity_tree_sticky_selection.setText(QCoreApplication.translate("SettingsForm", u"Sticky selection", None)) + self.checkBox_hide_empty_classes.setText(QCoreApplication.translate("SettingsForm", u"Hide empty classes", None)) + self.groupBox_entity_graph.setTitle(QCoreApplication.translate("SettingsForm", u"Entity graph", None)) + self.checkBox_smooth_entity_graph_rotation.setText(QCoreApplication.translate("SettingsForm", u"Smooth rotation", None)) + self.checkBox_smooth_entity_graph_zoom.setText(QCoreApplication.translate("SettingsForm", u"Smooth zoom", None)) + self.spinBox_layout_algo_max_iterations.setSuffix("") + self.label_10.setText(QCoreApplication.translate("SettingsForm", u"Minimum distance between nodes (%)", None)) + self.label_6.setText(QCoreApplication.translate("SettingsForm", u"Number of build iterations", None)) #if QT_CONFIG(tooltip) - self.checkBox_auto_expand_objects.setToolTip(QCoreApplication.translate("SettingsForm", u"

Checked: Whenever an object is included in the Entity graph, the graph automatically includes all its relationships.

Unchecked: Whenever all the objects in a relationship are included in the Entity graph, the graph automatically includes the relationship.

Note: This setting is a global default, but can be locally overriden in every Spine DB editor session.

", None)) + self.checkBox_auto_expand_entities.setToolTip(QCoreApplication.translate("SettingsForm", u"

Checked: Whenever an object is included in the Entity graph, the graph automatically includes all its related N-D entities.

Unchecked: Whenever all the elements in a N-D entity are included in the Entity graph, the graph automatically includes it.

Note: This setting is a global default, but can be locally overriden in every Spine DB editor session.

", None)) #endif // QT_CONFIG(tooltip) - self.checkBox_auto_expand_objects.setText(QCoreApplication.translate("SettingsForm", u"Auto-expand objects by default in Entity graph", None)) - self.checkBox_merge_dbs.setText(QCoreApplication.translate("SettingsForm", u"Merge databases by default in Entity graph", None)) + self.checkBox_auto_expand_entities.setText(QCoreApplication.translate("SettingsForm", u"Auto-expand entities", None)) + self.label_16.setText(QCoreApplication.translate("SettingsForm", u"Decay rate of attraction with distance", None)) + self.checkBox_snap_entities.setText(QCoreApplication.translate("SettingsForm", u"Snap entities to grid", None)) + self.checkBox_merge_dbs.setText(QCoreApplication.translate("SettingsForm", u"Merge databases", None)) + self.label_3.setText(QCoreApplication.translate("SettingsForm", u"Max. entity dimension count", None)) self.groupBox.setTitle(QCoreApplication.translate("SettingsForm", u"Specification editors", None)) #if QT_CONFIG(tooltip) self.checkBox_save_spec_before_closing.setToolTip(QCoreApplication.translate("SettingsForm", u"

Unchecked: Don't save specification and don't show message box

Partially checked: Show message box (default)

Checked: Save specification and don't show message box

", None)) diff --git a/spinetoolbox/ui/settings.ui b/spinetoolbox/ui/settings.ui index 914a121ff..d82a99369 100644 --- a/spinetoolbox/ui/settings.ui +++ b/spinetoolbox/ui/settings.ui @@ -2,6 +2,7 @@ - - Form - - - - 0 - 0 - 550 - 309 - - - - - 0 - 0 - - - - Form - - - - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> -p, li { white-space: pre-wrap; } -hr { height: 1px; border-width: 0; } -li.unchecked::marker { content: "\2610"; } -li.checked::marker { content: "\2612"; } -</style></head><body style=" font-family:'Segoe UI'; font-size:9pt; font-weight:400; font-style:normal;"> -<h1 style=" margin-top:18px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:xx-large; font-weight:700;">About Toolbox 0.8 update</span></h1> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Spine Toolbox development team has been working hard on the 0.8 update since early 2023. As the release date gets close (or it may have passed already), we thought it might be worth giving you a heads-up on what the update is about since there will be major changes to the user interface as well as to Spine data structure.</p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">We have tried to make sure that your projects and databases are migrated to 0.8 as painlessly as possible. Still, <span style=" font-style:italic;">backup your projects and databases before the update</span>, as there may be bugs. We also maintain Spine-Database-API almost fully backward compatible. However, there are some changes that may break your scripts. Please report all issues to our <a href="https://github.com/spine-tools/Spine-Toolbox/issues"><span style=" text-decoration: underline; color:#0000ff;">issue tracker</span></a>.</p> -<p style=" margin-top:12px; margin-bottom:12px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Head to <a href="https://github.com/spine-tools/Spine-Toolbox/blob/0.8-dev/CHANGELOG.md"><span style=" text-decoration: underline; color:#0000ff;">CHANGELOG.md</span></a> to get familiar with the coming changes. If you are using Spine-Database-API in your scripts, it might be worth checking its <a href="https://github.com/spine-tools/Spine-Database-API/blob/0.8-dev/CHANGELOG.md"><span style=" text-decoration: underline; color:#0000ff;">CHANGELOG.md</span></a> as well.</p></body></html> - - - true - - - - - - - QDialogButtonBox::Close - - - - - - - - diff --git a/spinetoolbox/ui_main.py b/spinetoolbox/ui_main.py index b125f2623..405c44a92 100644 --- a/spinetoolbox/ui_main.py +++ b/spinetoolbox/ui_main.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains ToolboxUI class. -""" - +"""Contains a class for the main window of Spine Toolbox.""" import os import sys import locale @@ -23,7 +21,6 @@ import numpy as np from PySide6.QtCore import ( QByteArray, - QItemSelection, QMimeData, QModelIndex, QPoint, @@ -34,7 +31,17 @@ QUrl, QEvent, ) -from PySide6.QtGui import QDesktopServices, QGuiApplication, QKeySequence, QIcon, QCursor, QWindow, QAction, QUndoStack +from PySide6.QtGui import ( + QDesktopServices, + QGuiApplication, + QKeySequence, + QIcon, + QCursor, + QWindow, + QAction, + QUndoStack, + QColor, +) from PySide6.QtWidgets import ( QMainWindow, QApplication, @@ -53,11 +60,8 @@ QHBoxLayout, ) from spine_engine.load_project_items import load_item_specification_factories -from spine_items.category import CATEGORIES, CATEGORY_DESCRIPTIONS from .project_item_icon import ProjectItemIcon from .load_project_items import load_project_items -from .mvcmodels.project_tree_item import CategoryProjectTreeItem, RootProjectTreeItem -from .mvcmodels.project_item_model import ProjectItemModel from .mvcmodels.project_item_specification_models import ProjectItemSpecificationModel, FilteredSpecificationModel from .mvcmodels.filter_execution_model import FilterExecutionModel from .project_settings import ProjectSettings @@ -70,7 +74,7 @@ from .widgets.jupyter_console_widget import JupyterConsoleWidget from .widgets.persistent_console_widget import PersistentConsoleWidget from .widgets import toolbars -from .widgets.open_project_widget import OpenProjectDialog +from .widgets.open_project_dialog import OpenProjectDialog from .widgets.jump_properties_widget import JumpPropertiesWidget from .widgets.link_properties_widget import LinkPropertiesWidget from .project import SpineToolboxProject @@ -111,7 +115,6 @@ from .project_item.logging_connection import LoggingConnection, LoggingJump from spinetoolbox.server.engine_client import EngineClient, RemoteEngineInitFailed, ClientSecurityModel from .kernel_fetcher import KernelFetcher -from .widgets.upgrade_notification import UpgradeNotificationDialog class ToolboxUI(QMainWindow): @@ -152,18 +155,16 @@ def __init__(self): self.ui.graphicsView.set_ui(self) self.key_press_filter = ChildCyclingKeyPressFilter(self) self.ui.tabWidget_item_properties.installEventFilter(self.key_press_filter) - self._share_item_edit_actions() + self._add_item_edit_actions() self.ui.listView_console_executions.setModel(FilterExecutionModel(self)) # Set style sheets self.setStyleSheet(MAINWINDOW_SS) # Class variables self.undo_stack = QUndoStack(self) - self._item_categories = dict() self._item_properties_uis = dict() self.item_factories = dict() # maps item types to `ProjectItemFactory` objects self._item_specification_factories = dict() # maps item types to `ProjectItemSpecificationFactory` objects self._project = None - self.project_item_model = None self.specification_model = None self.filtered_spec_factory_models = {} self.show_datetime = self.update_datetime() @@ -171,8 +172,7 @@ def __init__(self): self.active_link_item = None self._selected_item_names = set() self.execution_in_progress = False - self.sync_item_selection_with_scene = True - self._anchor_callbacks = {"show upgrade notification dialog": self._show_upgrade_notification_dialog} + self._anchor_callbacks = {} self.ui.textBrowser_eventlog.set_toolbox(self) # DB manager self.db_mngr = SpineDBManager(self._qsettings, self) @@ -182,10 +182,9 @@ def __init__(self): self.recent_projects_menu = RecentProjectsPopupMenu(self) self.kernels_menu = KernelsPopupMenu(self) # Make and initialize toolbars - self.main_toolbar = toolbars.MainToolBar( - self.ui.actionExecute_project, self.ui.actionExecute_selection, self.ui.actionStop_execution, self - ) - self.addToolBar(Qt.TopToolBarArea, self.main_toolbar) + self.items_toolbar = toolbars.ItemsToolBar(self) + self.spec_toolbar = toolbars.SpecToolBar(self) + self.execute_toolbar = toolbars.ExecuteToolBar(self) self._original_execute_project_action_tooltip = self.ui.actionExecute_project.toolTip() self.setStatusBar(None) # Additional consoles for item execution @@ -208,15 +207,14 @@ def __init__(self): self.set_debug_qactions() # Finalize init self.ui.tabWidget_item_properties.tabBar().hide() # Hide tab bar in properties dock widget - self.restore_dock_widgets() - self.restore_ui() self.ui.listView_console_executions.hide() self.ui.listView_console_executions.installEventFilter(self) self.parse_project_item_modules() - self.init_project_item_model() self.init_specification_model() self.make_item_properties_uis() - self.main_toolbar.setup() + self.items_toolbar.setup() + self.spec_toolbar.setup() + self.execute_toolbar.setup() self.link_properties_widgets = { LoggingConnection: LinkPropertiesWidget(self, base_color=LINK_COLOR), LoggingJump: JumpPropertiesWidget(self, base_color=JUMP_COLOR), @@ -227,6 +225,9 @@ def __init__(self): self.ui.tabWidget_item_properties.addTab(jump_tab, "Loop properties") self._plugin_manager = PluginManager(self) self._plugin_manager.load_installed_plugins() + self.refresh_toolbars() + self.restore_dock_widgets() + self.restore_ui() self.set_work_directory() self._disable_project_actions() self.connect_signals() @@ -307,33 +308,23 @@ def connect_signals(self): self.ui.actionRemove.triggered.connect(self._remove_selected_items) # Debug actions self.show_properties_tabbar.triggered.connect(self.toggle_properties_tabbar_visibility) - self.show_supported_img_formats.triggered.connect(supported_img_formats) # in helpers.py - # Context-menus - self.ui.treeView_project.customContextMenuRequested.connect(self.show_item_context_menu) + self.show_supported_img_formats.triggered.connect(supported_img_formats) # Undo stack self.undo_stack.cleanChanged.connect(self.update_window_modified) # Views self.ui.listView_console_executions.selectionModel().currentChanged.connect(self._select_console_execution) self.ui.listView_console_executions.model().layoutChanged.connect(self._refresh_console_execution_list) - self.ui.treeView_project.selectionModel().selectionChanged.connect(self.item_selection_changed) - # Models - self.project_item_model.rowsInserted.connect(self._update_execute_enabled) - self.project_item_model.rowsRemoved.connect(self._update_execute_enabled) # Execution self.ui.actionExecute_project.triggered.connect(self._execute_project) self.ui.actionExecute_selection.triggered.connect(self._execute_selection) self.ui.actionStop_execution.triggered.connect(self._stop_execution) # Open dir - self._button_item_dir.clicked.connect(self._open_active_item_dir) + self._button_item_dir.clicked.connect(self._open_project_item_directory) # Consoles self.jupyter_console_requested.connect(self._setup_jupyter_console) self.kernel_shutdown.connect(self._handle_kernel_shutdown) self.persistent_console_requested.connect(self._setup_persistent_console, Qt.BlockingQueuedConnection) - @Slot(bool) - def _open_active_item_dir(self, _checked=False): - self.active_project_item.open_directory() - @staticmethod def set_error_mode(): """Sets Windows error mode to show all error dialog boxes from subprocesses. @@ -360,24 +351,22 @@ def _update_qsettings(self): try: old_value = int(self._qsettings.value("appSettings/saveAtExit")) except ValueError: - # Old value is already of correct form. + # Old value is already of correct form pass else: new_value = {0: "prompt", 1: "prompt", 2: "automatic"}[old_value] self._qsettings.setValue("appSettings/saveAtExit", new_value) def _update_execute_enabled(self): - first_index = next(self.project_item_model.leaf_indexes(), None) enabled_by_project = self._project.settings.enable_execute_all if self._project is not None else False - self.ui.actionExecute_project.setEnabled( - enabled_by_project and first_index is not None and not self.execution_in_progress - ) + self.ui.actionExecute_project.setEnabled(enabled_by_project and not self.execution_in_progress) if not enabled_by_project: self.ui.actionExecute_project.setToolTip("Executing entire project disabled by project settings.") else: self.ui.actionExecute_project.setToolTip(self._original_execute_project_action_tooltip) def _update_execute_selected_enabled(self): + """Enables or disables execute selected action based on the number of selected items.""" has_selection = bool(self._selected_item_names) self.ui.actionExecute_selection.setEnabled(has_selection and not self.execution_in_progress) @@ -389,11 +378,11 @@ def update_window_modified(self, clean): def parse_project_item_modules(self): """Collects data from project item factories.""" - self._item_categories, self.item_factories = load_project_items("spine_items") + self.item_factories = load_project_items("spine_items") self._item_specification_factories = load_item_specification_factories("spine_items") def set_work_directory(self, new_work_dir=None): - """Creates a work directory if it does not exist or changes the current work directory to given one. + """Creates a work directory if it does not exist or changes the current work directory to given. Args: new_work_dir (str, optional): If given, changes the work directory to given @@ -459,7 +448,6 @@ def init_project(self, project_dir): welcome_msg = "Welcome to Spine Toolbox! If you need help, please read the {0} guide.".format( getting_started_anchor ) - self._notify_about_upgrades_at_startup() if not project_dir: open_previous_project = int(self._qsettings.value("appSettings/openPreviousProject", defaultValue="0")) if ( @@ -522,6 +510,7 @@ def create_project(self, proj_dir): if self._project is not None: if not self.close_project(): return + self.ui.textBrowser_eventlog.clear() self.undo_stack.clear() self._project = SpineToolboxProject( self, @@ -531,7 +520,6 @@ def create_project(self, proj_dir): settings=ProjectSettings(), logger=self, ) - self.project_item_model.connect_to_project(self._project) self.specification_model.connect_to_project(self._project) self._enable_project_actions() self.ui.actionSave.setDisabled(True) # Disable in a clean project @@ -574,7 +562,14 @@ def open_project(self, load_dir=None): load_dir = QFileDialog.getExistingDirectory(self, caption="Open Spine Toolbox Project", dir=start_dir) if not load_dir: return False # Cancelled - return self.restore_project(load_dir) + if not self.restore_project(load_dir): + if not self.undo_stack.isClean(): # If current project not saved, don't exit it + self.msg_warning.emit(f"Cancelled opening project {load_dir}. Current project has unsaved changes.") + return False + # If opening project failed, don't clear the event log so that the error message is visible + self.close_project(ask_confirmation=False, clear_event_log=False) + return False + return True def restore_project(self, project_dir, ask_confirmation=True): """Initializes UI, Creates project, models, connections, etc., when opening a project. @@ -598,7 +593,6 @@ def restore_project(self, project_dir, ask_confirmation=True): settings=ProjectSettings(), logger=self, ) - self.project_item_model.connect_to_project(self._project) self.specification_model.connect_to_project(self._project) self._enable_project_actions() self.ui.actionSave.setDisabled(True) # Save is disabled in a clean project @@ -610,7 +604,6 @@ def restore_project(self, project_dir, ask_confirmation=True): self.remove_path_from_recent_projects(self._project.project_dir) return False self._plugin_manager.reload_plugins_with_local_data() - self.ui.treeView_project.expandAll() # Reset zoom on Design View self.ui.graphicsView.reset_zoom() self.update_recent_projects() @@ -619,9 +612,14 @@ def restore_project(self, project_dir, ask_confirmation=True): def _toolbars(self): """Yields all toolbars in the window.""" - yield self.main_toolbar + yield self.items_toolbar + yield self.spec_toolbar yield from self._plugin_manager.plugin_toolbars.values() + def set_toolbar_colored_icons(self, checked): + for toolbar in self._toolbars(): + toolbar.set_colored_icons(checked) + def _disable_project_actions(self): """Disables all project-related actions, except New project, Open project and Open recent. Called @@ -650,11 +648,16 @@ def _enable_project_actions(self): self._unset_execution_in_progress() def refresh_toolbars(self): - """Set toolbars' color using highest possible contrast.""" + """Set toolbars' color using the highest possible contrast.""" all_toolbars = list(self._toolbars()) for k, toolbar in enumerate(all_toolbars): color = color_from_index(k, len(all_toolbars), base_hue=217.0, saturation=0.6) toolbar.set_color(color) + if self.toolBarArea(toolbar) == Qt.NoToolBarArea: + self.addToolBar(Qt.TopToolBarArea, toolbar) + self.execute_toolbar.set_color(QColor("silver")) + if self.toolBarArea(self.execute_toolbar) == Qt.NoToolBarArea: + self.addToolBar(Qt.TopToolBarArea, self.execute_toolbar) @Slot() def show_recent_projects_menu(self): @@ -732,11 +735,12 @@ def save_project_as(self): # noinspection PyCallByClass, PyArgumentList QMessageBox.information(self, f"Project {self._project.name} saved", f"Project directory is now\n\n{answer}") - def close_project(self, ask_confirmation=True): + def close_project(self, ask_confirmation=True, clear_event_log=True): """Closes the current project. Args: ask_confirmation (bool): if False, no confirmation whatsoever is asked from user + clear_event_log (bool): if True, the event log is cleared after closing the project Returns: bool: True when no project open or when it's closed successfully, False otherwise. @@ -755,9 +759,10 @@ def close_project(self, ask_confirmation=True): self._project.tear_down() self._project = None self._disable_project_actions() - self.undo_stack.setClean() + self.undo_stack.clear() self.update_window_title() - self.ui.textBrowser_eventlog.clear() + if clear_event_log: + self.ui.textBrowser_eventlog.clear() return True @Slot(bool) @@ -768,15 +773,6 @@ def set_project_description(self, _=False): dialog = SetDescriptionDialog(self, self._project) dialog.show() - def init_project_item_model(self): - """Initializes project item model. Create root and category items and add them to the model.""" - root_item = RootProjectTreeItem() - self.project_item_model = ProjectItemModel(root_item, self) - for category in CATEGORIES: - category_item = CategoryProjectTreeItem(str(category), CATEGORY_DESCRIPTIONS[category]) - self.project_item_model.insert_item(category_item) - self.ui.treeView_project.setModel(self.project_item_model) - def init_specification_model(self): """Initializes specification model.""" factory_icons = {item_type: QIcon(factory.icon()) for item_type, factory in self.item_factories.items()} @@ -805,16 +801,15 @@ def _make_properties_tab(self, properties_ui): layout.addWidget(properties_ui) return tab - def add_project_items(self, items_dict, silent=False): + def add_project_items(self, items_dict): """Pushes an AddProjectItemsCommand to the undo stack. Args: items_dict (dict): mapping from item name to item dictionary - silent (bool): if True, suppress log messages """ if self._project is None or not items_dict: return - self.undo_stack.push(AddProjectItemsCommand(self._project, items_dict, self.item_factories, silent)) + self.undo_stack.push(AddProjectItemsCommand(self._project, items_dict, self.item_factories)) def supports_specifications(self, item_type): """Returns True if given project item type supports specifications. @@ -939,22 +934,6 @@ def overwrite_check(self, project_dir): return False return True - @Slot(QItemSelection, QItemSelection) - def item_selection_changed(self, selected, deselected): - """Synchronizes selection with scene. The scene handles item/link de/activation.""" - inds = self.ui.treeView_project.selectedIndexes() - self._selected_item_names = { - self.project_item_model.item(i).name for i in self.ui.treeView_project.selectedIndexes() - } - self._update_execute_selected_enabled() - if not self.sync_item_selection_with_scene: - return - project_items = [self.project_item_model.item(i).project_item for i in inds] - project_item_names = {i.name for i in project_items} - scene = self.ui.graphicsView.scene() - for icon in scene.project_item_icons(): - icon.setSelected(icon.name() in project_item_names) - def refresh_active_elements(self, active_project_item, active_link_item, selected_item_names): self._selected_item_names = selected_item_names self._update_execute_selected_enabled() @@ -975,9 +954,10 @@ def _activate_properties_tab(self): self.activate_no_selection_tab() def _set_active_project_item(self, active_project_item): - """ + """Activates given project item. + Args: - active_project_item (ProjectItemBase or NoneType) + active_project_item (ProjectItemBase or NoneType): Active project item """ if self.active_project_item == active_project_item: return @@ -994,11 +974,10 @@ def _set_active_project_item(self, active_project_item): self._item_properties_uis[self.active_project_item.item_type()].set_item(self.active_project_item) def _set_active_link_item(self, active_link_item): - """ - Sets active link and connects to corresponding properties widget. + """Activates given link and connects it to the corresponding Properties widget. Args: - active_link_item (LoggingConnection or LoggingJump, optional) + active_link_item (LoggingConnection or LoggingJump, optional): Active link """ if self.active_link_item is active_link_item: return @@ -1052,6 +1031,7 @@ def update_properties_ui(self): widget.repaint() def _get_active_properties_widget(self): + """Returns the active item's or link's properties widget or None if no item or link is active.""" if self.active_project_item is not None: return self._item_properties_uis[self.active_project_item.item_type()] if self.active_link_item is not None: @@ -1100,7 +1080,7 @@ def repair_specification(self, name): """Repairs specification if it is broken. Args: - name (str): specification's name + name (str): Specification's name """ specification = self._project.get_specification(name) item_factory = self.item_factories.get(specification.item_type) @@ -1111,12 +1091,12 @@ def prompt_save_location(self, title, proposed_path, file_filter): """Shows a dialog for the user to select a path to save a file. Args: - title (str): dialog window title - proposed_path (str): A proposed location. - file_filter (str): file extension filter + title (str): Dialog window title + proposed_path (str): Proposed location. + file_filter (str): File extension filter Returns: - str: absolute path or None if dialog was cancelled + str: Absolute path or None if dialog was cancelled """ answer = QFileDialog.getSaveFileName(self, title, proposed_path, file_filter) if not answer[0]: # Cancel button clicked @@ -1129,8 +1109,8 @@ def _log_specification_saved(self, name, path): together with a clickable link to change the location. Args: - name (str): specification's name - path (str): specification's file path + name (str): Specification's name + path (str): Specification's file path """ self.msg_success.emit( f"Specification {name} successfully saved as " @@ -1168,8 +1148,8 @@ def register_anchor_callback(self, url, callback): Used by ``ToolFactory.repair_specification()``. Args: - url (str): The anchor url - callback (function): A function to call when the anchor is clicked on event log. + url (str): Anchor url + callback (function): Function to call when the anchor is clicked """ self._anchor_callbacks[url] = callback @@ -1203,7 +1183,7 @@ def _change_specification_file_location(self, name): otherwise tries to find the specification from the plugin manager. Args: - name (str): specification's name + name (str): Specification's name """ if self._project is not None: if not self._project.is_specification_name_reserved(name): @@ -1262,14 +1242,14 @@ def edit_specification(self, index, item): index (QModelIndex): Index of the item (from double-click or context menu signal) item (ProjectItem, optional) """ - if not index.isValid() or not item: + if not index.isValid(): return specification = self.specification_model.specification(index.row()) # Open spec in Tool specification edit widget - if item.item_type() == "Importer": + if item and item.item_type() == "Importer": item.edit_specification() - else: - self.show_specification_form(specification.item_type, specification, item) + return + self.show_specification_form(specification.item_type, specification, item) @Slot(QModelIndex) def remove_specification(self, index): @@ -1359,17 +1339,15 @@ def restore_dock_widgets(self): dock.setMinimumSize(0, 0) dock.setVisible(True) dock.setFloating(False) - self.splitDockWidget(self.ui.dockWidget_project, self.ui.dockWidget_eventlog, Qt.Orientation.Vertical) self.splitDockWidget(self.ui.dockWidget_eventlog, self.ui.dockWidget_console, Qt.Orientation.Horizontal) self.ui.dockWidget_eventlog.raise_() - self.splitDockWidget(self.ui.dockWidget_project, self.ui.dockWidget_design_view, Qt.Orientation.Horizontal) self.splitDockWidget(self.ui.dockWidget_design_view, self.ui.dockWidget_item, Qt.Orientation.Horizontal) - docks = (self.ui.dockWidget_project, self.ui.dockWidget_design_view, self.ui.dockWidget_item) - width = sum(d.size().width() for d in docks) - self.resizeDocks(docks, [0.2 * width, 0.5 * width, 0.3 * width], Qt.Orientation.Horizontal) - docks = (self.ui.dockWidget_project, self.ui.dockWidget_eventlog) - width = sum(d.size().width() for d in docks) - self.resizeDocks(docks, [0.6 * width, 0.4 * width], Qt.Orientation.Vertical) + top_docks = (self.ui.dockWidget_design_view, self.ui.dockWidget_item) + width = sum(d.size().width() for d in top_docks) + self.resizeDocks(top_docks, [0.7 * width, 0.3 * width], Qt.Orientation.Horizontal) + bottom_docks = (self.ui.dockWidget_eventlog, self.ui.dockWidget_console) + # bottom_width = sum(d.size().width() for d in bottom_docks) + self.resizeDocks(bottom_docks, [0.5 * width, 0.5 * width], Qt.Orientation.Horizontal) def _add_execute_actions(self): """Adds execution handler actions to the main window.""" @@ -1379,16 +1357,17 @@ def _add_execute_actions(self): def set_debug_qactions(self): """Sets shortcuts for QActions that may be needed in debugging.""" - self.show_properties_tabbar.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_0)) - self.show_supported_img_formats.setShortcut(QKeySequence(Qt.CTRL | Qt.Key_8)) + self.show_properties_tabbar.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_0.value)) + self.show_supported_img_formats.setShortcut(QKeySequence(Qt.Modifier.CTRL.value | Qt.Key.Key_8.value)) self.addAction(self.show_properties_tabbar) self.addAction(self.show_supported_img_formats) def add_menu_actions(self): """Adds extra actions to Edit and View menu.""" - self.ui.menuToolbars.addAction(self.main_toolbar.toggleViewAction()) + self.ui.menuToolbars.addAction(self.items_toolbar.toggleViewAction()) + self.ui.menuToolbars.addAction(self.spec_toolbar.toggleViewAction()) + self.ui.menuToolbars.addAction(self.execute_toolbar.toggleViewAction()) self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_design_view.toggleViewAction()) - self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_project.toggleViewAction()) self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_eventlog.toggleViewAction()) self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_item.toggleViewAction()) self.ui.menuDock_Widgets.addAction(self.ui.dockWidget_console.toggleViewAction()) @@ -1418,7 +1397,7 @@ def update_datetime(self): @Slot(str) def add_message(self, msg): - """Append regular message to Event Log. + """Appends a regular message to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1428,7 +1407,7 @@ def add_message(self, msg): @Slot(str) def add_success_message(self, msg): - """Append message with green text color to Event Log. + """Appends a message with green text to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1438,7 +1417,7 @@ def add_success_message(self, msg): @Slot(str) def add_error_message(self, msg): - """Append message with red color to Event Log. + """Appends a message with red color to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1448,7 +1427,7 @@ def add_error_message(self, msg): @Slot(str) def add_warning_message(self, msg): - """Append message with yellow (golden) color to Event Log. + """Appends a message with yellow (golden) color to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1458,7 +1437,7 @@ def add_warning_message(self, msg): @Slot(str) def add_process_message(self, msg): - """Writes message from stdout to process output QTextBrowser. + """Writes message from stdout to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1468,7 +1447,7 @@ def add_process_message(self, msg): @Slot(str) def add_process_error_message(self, msg): - """Writes message from stderr to process output QTextBrowser. + """Writes message from stderr to the Event Log. Args: msg (str): String written to QTextBrowser @@ -1564,11 +1543,10 @@ def show_add_project_item_form(self, item_type, x=0, y=0, spec=""): self.add_project_item_form.show() def supports_specification(self, item_type): - """ - Returns True if given item type supports specifications. + """Returns True if given item type supports specifications. Args: - item_type (str): item's type + item_type (str): Item's type Returns: bool: True if item supports specifications, False otherwise @@ -1577,24 +1555,19 @@ def supports_specification(self, item_type): @Slot() def show_specification_form(self, item_type, specification=None, item=None, **kwargs): - """ - Shows specification widget. + """Shows specification widget. Args: - item_type (str): item's type - specification (ProjectItemSpecification, optional): specification - item (ProjectItem, optional): project item - **kwargs: parameters passed to the specification widget + item_type (str): Item's type + specification (ProjectItemSpecification, optional): Specification + item (ProjectItem, optional): Project item + **kwargs: Parameters passed to the specification widget """ if not self._project: self.msg.emit("Please open or create a project first") return if not self.supports_specification(item_type): return - msg = f"Opening {item_type} specification editor" - if specification: - msg += f" for {specification.name}" - self.msg.emit(msg) multi_tab_editor = next(self.get_all_multi_tab_spec_editors(item_type), None) if multi_tab_editor is None: multi_tab_editor = MultiTabSpecEditor(self, item_type) @@ -1629,20 +1602,20 @@ def _get_existing_spec_editor(self, item_type, specification, item): @Slot() def show_settings(self): - """Show Settings widget.""" + """Shows the Settings widget.""" self.settings_form = SettingsWidget(self) self.settings_form.show() @Slot() def show_about(self): - """Show About Spine Toolbox form.""" + """Shows the About Spine Toolbox widget.""" form = AboutWidget(self) form.show() # pylint: disable=no-self-use @Slot() def show_user_guide(self): - """Open Spine Toolbox documentation index page in browser.""" + """Opens Spine Toolbox documentation index page in browser.""" index_url = f"{ONLINE_DOCUMENTATION_URL}/index.html" # noinspection PyTypeChecker, PyCallByClass, PyArgumentList open_url(index_url) @@ -1650,7 +1623,7 @@ def show_user_guide(self): # pylint: disable=no-self-use @Slot() def show_getting_started_guide(self): - """Open Spine Toolbox Getting Started HTML page in browser.""" + """Opens Spine Toolbox Getting Started HTML page in browser.""" index_url = f"{ONLINE_DOCUMENTATION_URL}/getting_started.html" # noinspection PyTypeChecker, PyCallByClass, PyArgumentList open_url(index_url) @@ -1729,36 +1702,21 @@ def engine_server_settings(self): ) return host, port, sec_model, sec_folder - @Slot(QPoint) - def show_item_context_menu(self, pos): - """Context menu for project items listed in the project QTreeView. - - Args: - pos (QPoint): Mouse position - """ - ind = self.ui.treeView_project.indexAt(pos) - global_pos = self.ui.treeView_project.viewport().mapToGlobal(pos) - self.show_project_or_item_context_menu(global_pos, ind) - - def show_project_or_item_context_menu(self, pos, index): + def show_project_or_item_context_menu(self, pos, item): """Creates and shows the project item context menu. Args: pos (QPoint): Mouse position - index (QModelIndex, optional): Index of concerned item or None + item (ProjectItem, optional): Project item or None """ - if not index: # Clicked on a blank area in Design view + if not item: # Clicked on a blank area in Design view menu = QMenu(self) menu.addAction(self.ui.actionPaste) menu.addAction(self.ui.actionPasteAndDuplicateFiles) menu.addSeparator() menu.addAction(self.ui.actionOpen_project_directory) - elif not index.isValid(): # Clicked on a blank area in Project tree view - menu = QMenu(self) - menu.addAction(self.ui.actionOpen_project_directory) - else: # Clicked on an item, show the custom context menu for that item - item = self.project_item_model.item(index) - menu = item.custom_context_menu(self) + else: # Clicked on an item, show the context menu for that item + menu = self.project_item_context_menu(item.actions()) menu.setToolTipsVisible(True) menu.aboutToShow.connect(self.refresh_edit_action_states) menu.aboutToHide.connect(self.enable_edit_actions) @@ -1766,11 +1724,11 @@ def show_project_or_item_context_menu(self, pos, index): menu.deleteLater() def show_link_context_menu(self, pos, link): - """Context menu for connection links. + """Shows the Context menu for connection links. Args: pos (QPoint): Mouse position - link (Link(QGraphicsPathItem)): The concerned link + link (Link(QGraphicsPathItem)): The link in question """ menu = QMenu(self) menu.addAction(self.ui.actionRemove) @@ -1779,36 +1737,56 @@ def show_link_context_menu(self, pos, link): action = menu.exec(pos) if action is self.ui.actionTake_link: self.ui.graphicsView.take_link(link) - self.refresh_edit_action_states() + self.refresh_edit_action_states() # Disables actionRemove because take_link clears selections + self.ui.actionRemove.setEnabled(True) menu.deleteLater() @Slot() def refresh_edit_action_states(self): """Sets the enabled/disabled state for copy, paste, duplicate, - and remove actions in File-Edit menu, project tree view + remove and rename actions in File-Edit menu, project tree view context menu, and in Design View context menus just before the menus are shown to user.""" + if not self.project(): + self.disable_edit_actions() + return clipboard = QApplication.clipboard() byte_data = clipboard.mimeData().data("application/vnd.spinetoolbox.ProjectItem") can_paste = not byte_data.isNull() selected_items = self.ui.graphicsView.scene().selectedItems() - has_selection = bool(selected_items) can_copy = any(isinstance(x, ProjectItemIcon) for x in selected_items) - has_items = self.project_item_model.n_items() > 0 + has_items = self.project().n_items > 0 selected_project_items = [x for x in selected_items if isinstance(x, ProjectItemIcon)] - _methods = [ - getattr(self.project_item_model.get_item(x.name()).project_item, "copy_local_data") - for x in selected_project_items - ] + _methods = [getattr(self.project().get_item(x.name()), "copy_local_data") for x in selected_project_items] can_duplicate_files = any(m.__qualname__.partition(".")[0] != "ProjectItem" for m in _methods) + # Renaming an item should always be allowed except when it's a Data Store that is open in an editor + for item in (self.project().get_item(x.name()) for x in selected_project_items): + if item.item_type() == "Data Store" and item.has_listeners(): + self.ui.actionRename_item.setEnabled(False) + self.ui.actionRename_item.setToolTip( + f"

The editor for {item.name} needs to be closed before renaming.

" + ) + else: + self.ui.actionRename_item.setEnabled(True) + self.ui.actionRename_item.setToolTip("Rename project item") self.ui.actionCopy.setEnabled(can_copy) self.ui.actionPaste.setEnabled(can_paste) self.ui.actionPasteAndDuplicateFiles.setEnabled(can_paste) self.ui.actionDuplicate.setEnabled(can_copy) self.ui.actionDuplicateAndDuplicateFiles.setEnabled(can_duplicate_files) - self.ui.actionRemove.setEnabled(has_selection) + self.ui.actionRemove.setEnabled(bool(selected_items)) self.ui.actionRemove_all.setEnabled(has_items) + def disable_edit_actions(self): + """Disables edit actions.""" + self.ui.actionCopy.setEnabled(False) + self.ui.actionPaste.setEnabled(False) + self.ui.actionPasteAndDuplicateFiles.setEnabled(False) + self.ui.actionDuplicate.setEnabled(False) + self.ui.actionDuplicateAndDuplicateFiles.setEnabled(False) + self.ui.actionRemove.setEnabled(False) + self.ui.actionRemove_all.setEnabled(False) + @Slot() def enable_edit_actions(self): """Enables project item edit actions after a QMenu has been shown. @@ -1819,10 +1797,10 @@ def enable_edit_actions(self): self.ui.actionPasteAndDuplicateFiles.setEnabled(True) self.ui.actionDuplicate.setEnabled(True) self.ui.actionDuplicateAndDuplicateFiles.setEnabled(True) + self.ui.actionRemove.setEnabled(True) def _tasks_before_exit(self): - """ - Returns a list of tasks to perform before exiting the application. + """Returns a list of tasks to perform before exiting the application. Possible tasks are: @@ -1831,7 +1809,7 @@ def _tasks_before_exit(self): - `"save"`: save project before quitting Returns: - a list containing zero or more tasks + list: Zero or more tasks in a list """ save_at_exit = ( self._qsettings.value("appSettings/saveAtExit", defaultValue="prompt") @@ -1849,11 +1827,10 @@ def _tasks_before_exit(self): return tasks def _perform_pre_exit_tasks(self): - """ - Prompts user to confirm quitting and saves the project if necessary. + """Prompts user to confirm quitting and saves the project if necessary. Returns: - True if exit should proceed, False if the process was cancelled + bool: True if exit should proceed, False if the process was cancelled """ tasks = self._tasks_before_exit() for task in tasks: @@ -1868,11 +1845,10 @@ def _perform_pre_exit_tasks(self): return True def _confirm_exit(self): - """ - Confirms exiting from user. + """Confirms exiting from user. Returns: - True if exit should proceed, False if user cancelled + bool: True if exit should proceed, False if user cancelled """ msg = QMessageBox(parent=self) msg.setIcon(QMessageBox.Icon.Question) @@ -1895,11 +1871,10 @@ def _confirm_exit(self): return False def _confirm_project_close(self): - """ - Confirms exit from user and saves the project if requested. + """Confirms exit from user and saves the project if requested. Returns: - True if exiting should proceed, False if user cancelled + bool: True if exiting should proceed, False if user cancelled """ msg = QMessageBox(parent=self) msg.setIcon(QMessageBox.Icon.Question) @@ -1999,7 +1974,7 @@ def closeEvent(self, event): else: self._qsettings.setValue("appSettings/previousProject", self._project.project_dir) self.update_recent_projects() - self._qsettings.setValue("appSettings/toolbarIconOrdering", self.main_toolbar.icon_ordering()) + self._qsettings.setValue("appSettings/toolbarIconOrdering", self.items_toolbar.icon_ordering()) self._qsettings.setValue("mainWindow/windowSize", self.size()) self._qsettings.setValue("mainWindow/windowPosition", self.pos()) self._qsettings.setValue("mainWindow/windowState", self.saveState(version=1)) @@ -2017,8 +1992,7 @@ def closeEvent(self, event): event.accept() def _serialize_selected_items(self): - """ - Serializes selected project items into a dictionary. + """Serializes selected project items into a dictionary. The serialization protocol tries to imitate the format in which projects are saved. @@ -2031,8 +2005,7 @@ def _serialize_selected_items(self): if not isinstance(item_icon, ProjectItemIcon): continue name = item_icon.name() - index = self.project_item_model.find_item(name) - project_item = self.project_item_model.item(index).project_item + project_item = self.project().get_item(name) item_dict = dict(project_item.item_dict()) item_dict["original_data_dir"] = project_item.data_dir item_dict["original_db_url"] = item_dict.get("url") @@ -2040,15 +2013,15 @@ def _serialize_selected_items(self): return items_dict def _deserialized_item_position_shifts(self, item_dicts): - """ - Calculates horizontal and vertical shifts for project items being deserialized. + """Calculates horizontal and vertical shifts for project items being deserialized. If the mouse cursor is on the Design view we try to place the items unders the cursor. - Otherwise the items will get a small shift so they don't overlap a possible item below. + Otherwise, the items will get a small shift, so they don't overlap a possible item below. In case the items don't fit the scene rect we clamp their coordinates within it. Args: - item_dicts (dict): a dictionary of serialized items being deserialized + item_dicts (dict): Dictionary of serialized items being deserialized + Returns: tuple: a tuple of (horizontal shift, vertical shift) in scene's coordinates """ @@ -2077,11 +2050,10 @@ def _set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect): item_dict["y"] = new_y def _deserialize_items(self, items_dict, duplicate_files=False): - """ - Deserializes project items from a dictionary and adds them to the current project. + """Deserializes project items from a dictionary and adds them to the current project. Args: - items_dict (dict): serialized project items + items_dict (dict): Serialized project items """ if self._project is None: return @@ -2092,13 +2064,13 @@ def _deserialize_items(self, items_dict, duplicate_files=False): final_items_dict = dict() for name, item_dict in items_dict.items(): item_dict["duplicate_files"] = duplicate_files - if self.project_item_model.find_item(name) is not None: + if name in self.project().all_item_names: new_name = unique_name(name, self.project().all_item_names) final_items_dict[new_name] = item_dict else: final_items_dict[name] = item_dict self._set_deserialized_item_position(item_dict, shift_x, shift_y, scene_rect) - self.add_project_items(final_items_dict, silent=True) + self.add_project_items(final_items_dict) @Slot() def project_item_to_clipboard(self): @@ -2136,8 +2108,8 @@ def duplicate_project_item(self, duplicate_files=False): return self._deserialize_items(item_dicts, duplicate_files) - def _share_item_edit_actions(self): - """Adds generic actions to project tree view and Design View.""" + def _add_item_edit_actions(self): + """Adds generic actions to Design View.""" actions = [ self.ui.actionCopy, self.ui.actionPaste, @@ -2148,7 +2120,6 @@ def _share_item_edit_actions(self): ] for action in actions: action.setShortcutContext(Qt.WidgetShortcut) - self.ui.treeView_project.addAction(action) self.ui.graphicsView.addAction(action) @Slot(str, str) @@ -2158,6 +2129,7 @@ def _show_message_box(self, title, message): @Slot(str, str) def _show_error_box(self, title, message): + """Shows an error message with the given title and message.""" box = QErrorMessage(self) box.setWindowTitle(title) box.setWindowModality(Qt.ApplicationModal) @@ -2225,7 +2197,7 @@ def set_icon_and_properties_ui(self, item_name): """Adds properties UI to given project item. Args: - item_name (str): item's name + item_name (str): Item's name """ project_item = self._project.get_item(item_name) icon = self.project_item_icon(project_item.item_type()) @@ -2237,10 +2209,10 @@ def project_item_properties_ui(self, item_type): """Returns the properties tab widget's ui. Args: - item_type (str): project item's type + item_type (str): Project item's type Returns: - QWidget: item's properties tab widget + QWidget: Item's properties tab widget """ return self._item_properties_uis[item_type].ui @@ -2257,13 +2229,8 @@ def _open_project_directory(self, _): @Slot(bool) def _open_project_item_directory(self, _): - """Opens project item's directory in system's file browser.""" - selection_model = self.ui.treeView_project.selectionModel() - current = selection_model.currentIndex() - if not current.isValid(): - return - item = self.project_item_model.item(current) - item.project_item.open_directory() + """Opens active project item's directory in system's file browser.""" + self.active_project_item.open_directory() @Slot(bool) def _remove_selected_items(self, _): @@ -2309,12 +2276,8 @@ def _remove_selected_items(self, _): @Slot(bool) def _rename_project_item(self, _): - """Renames current project item.""" - selection_model = self.ui.treeView_project.selectionModel() - current = selection_model.currentIndex() - if not current.isValid(): - return - item = self.project_item_model.item(current) + """Renames active project item.""" + item = self.active_project_item answer = QInputDialog.getText( self, "Rename Item", "New name:", text=item.name, flags=Qt.WindowTitleHint | Qt.WindowCloseButtonHint ) @@ -2323,25 +2286,14 @@ def _rename_project_item(self, _): new_name = answer[0] self.undo_stack.push(RenameProjectItemCommand(self._project, item.name, new_name)) - def item_category_context_menu(self): - """Creates a context menu for category items. - - Returns: - QMenu: category context menu - """ - menu = QMenu(self) - menu.setToolTipsVisible(True) - menu.addAction(self.ui.actionOpen_project_directory) - return menu - def project_item_context_menu(self, additional_actions): """Creates a context menu for project items. Args: - additional_actions (list of QAction): actions to be prepended to the menu + additional_actions (list of QAction): Actions to be prepended to the menu Returns: - QMenu: project item context menu + QMenu: Project item context menu """ menu = QMenu(self) menu.setToolTipsVisible(True) @@ -2543,13 +2495,13 @@ def restore_and_activate(self): def _make_log_entry_title(title): return f'{title}' - def start_execution(self, timestamp): - """Starts execution. + def make_execution_timestamp(self, timestamp): + """Appends a timestamp to Event Log. Args: - timestamp (str): time stamp + timestamp (str): Time stamp """ - self.ui.textBrowser_eventlog.start_execution(timestamp) + self.ui.textBrowser_eventlog.make_log_entry_point(timestamp) def add_log_message(self, item_name, filter_id, message): """Adds a message to an item's execution log. @@ -2560,22 +2512,3 @@ def add_log_message(self, item_name, filter_id, message): message (str): formatted message """ self.ui.textBrowser_eventlog.add_log_message(item_name, filter_id, message) - - def _notify_about_upgrades_at_startup(self): - """Shows upgrade information at application start-up.""" - upgrade_notification_anchor = ( - "Click here" - ) - upgrade_msg = f"{upgrade_notification_anchor} to learn more about the upcoming 0.8 upgrade" - self.msg_warning.emit(upgrade_msg) - key = "appSettings/showUpgradeNotification" - show_notification_dialog = self._qsettings.value(key, defaultValue=True, type=bool) - if show_notification_dialog: - self._show_upgrade_notification_dialog() - self._qsettings.setValue(key, False) - - def _show_upgrade_notification_dialog(self): - """Shows the Upgrade notification dialog.""" - dialog = UpgradeNotificationDialog(self) - dialog.open() diff --git a/spinetoolbox/version.py b/spinetoolbox/version.py index e45beae8a..172b229d1 100644 --- a/spinetoolbox/version.py +++ b/spinetoolbox/version.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,9 @@ # this program. If not, see . ###################################################################################################################### -"""Version info for Spine Toolbox package. Inspired by python sys.version and sys.version_info. -""" - +"""Version info for Spine Toolbox package. Inspired by python sys.version and sys.version_info.""" import re from typing import NamedTuple - from ._version import version_tuple @@ -32,9 +30,9 @@ class VersionInfo(NamedTuple): def __str__(self) -> str: """Create a version string following PEP 440""" version = f"{self.major}.{self.minor}.{self.micro}" - if self.releaselevel == 'final': # pylint: disable=no-else-return + if self.releaselevel == "final": # pylint: disable=no-else-return return version - elif self.releaselevel.startswith('dev'): + elif self.releaselevel.startswith("dev"): return version + f".dev{self.serial}" else: return version + f"-{self.releaselevel}.{self.serial}" diff --git a/spinetoolbox/widgets/__init__.py b/spinetoolbox/widgets/__init__.py index 0b60c4d9c..c205398e5 100644 --- a/spinetoolbox/widgets/__init__.py +++ b/spinetoolbox/widgets/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Init file for widgets package. Intentionally empty. -""" +"""Init file for widgets package. Intentionally empty.""" diff --git a/spinetoolbox/widgets/about_widget.py b/spinetoolbox/widgets/about_widget.py index 04fcd65d9..ff7756ba5 100644 --- a/spinetoolbox/widgets/about_widget.py +++ b/spinetoolbox/widgets/about_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A widget for presenting basic information about the application. -""" - +"""A widget for presenting basic information about the application.""" import os import sys import platform diff --git a/spinetoolbox/widgets/add_project_item_widget.py b/spinetoolbox/widgets/add_project_item_widget.py index a5cfb4596..df9f5b354 100644 --- a/spinetoolbox/widgets/add_project_item_widget.py +++ b/spinetoolbox/widgets/add_project_item_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Widget shown to user when a new Project Item is created. -""" - +"""Widget shown to user when a new Project Item is created.""" from PySide6.QtWidgets import QWidget, QStatusBar from PySide6.QtCore import Slot, Qt from spine_engine.utils.helpers import shorten @@ -51,20 +49,20 @@ def __init__(self, toolbox, x, y, class_, spec=""): if toolbox.supports_specifications(class_.item_type()): self.ui.comboBox_specification.setModel(toolbox.filtered_spec_factory_models[class_.item_type()]) if spec: - self.ui.comboBox_specification.setCurrentText(spec) + self.ui.comboBox_specification.hide() prefix = spec else: prefix = class_.item_type() self.ui.comboBox_specification.setCurrentIndex(-1) else: prefix = class_.item_type() - self.ui.comboBox_specification.setEnabled(False) + self.ui.comboBox_specification.hide() existing_item_names = toolbox.project().all_item_names self.name = unique_name(prefix, existing_item_names) if prefix in existing_item_names else prefix - self.ui.lineEdit_name.setText(self.name) - self.ui.lineEdit_name.selectAll() self.description = "" self.connect_signals() + self.ui.lineEdit_name.setText(self.name) + self.ui.lineEdit_name.selectAll() self.ui.lineEdit_name.setFocus() # Ensure this window gets garbage-collected when closed self.setAttribute(Qt.WA_DeleteOnClose) @@ -109,6 +107,10 @@ def handle_ok_clicked(self): self.statusbar.showMessage(msg, 3000) return self.call_add_item() + self._toolbox.ui.graphicsView.scene().clearSelection() + for icon in self._toolbox.ui.graphicsView.scene().project_item_icons(): + if icon.name() == self.name: + icon.setSelected(True) self.close() def call_add_item(self): diff --git a/spinetoolbox/widgets/add_up_spine_opt_wizard.py b/spinetoolbox/widgets/add_up_spine_opt_wizard.py index 0ed3f6cda..328ac963e 100644 --- a/spinetoolbox/widgets/add_up_spine_opt_wizard.py +++ b/spinetoolbox/widgets/add_up_spine_opt_wizard.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QDialogs for julia setup. -""" - +"""Classes for custom QDialogs for julia setup.""" from enum import IntEnum, auto from PySide6.QtWidgets import ( QWidget, @@ -169,11 +167,11 @@ def initializePage(self): args = [ f"--project={julia_project}", "-e", - 'import Pkg; ' + "import Pkg; " 'manifest = joinpath(dirname(Base.active_project()), "Manifest.toml"); ' - 'pkgs = isfile(manifest) ? Pkg.TOML.parsefile(manifest) : Dict(); ' + "pkgs = isfile(manifest) ? Pkg.TOML.parsefile(manifest) : Dict(); " 'manifest_format = get(pkgs, "manifest_format", missing); ' - 'if manifest_format === missing ' + "if manifest_format === missing " 'spine_opt = get(pkgs, "SpineOpt", nothing) ' 'else spine_opt = get(pkgs["deps"], "SpineOpt", nothing) end; ' 'if spine_opt != nothing println(spine_opt[1]["version"]) end; ', diff --git a/spinetoolbox/widgets/array_editor.py b/spinetoolbox/widgets/array_editor.py index 990893548..9a68b0b52 100644 --- a/spinetoolbox/widgets/array_editor.py +++ b/spinetoolbox/widgets/array_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,13 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains an editor widget for array type parameter values. -""" - +"""Contains an editor widget for array type parameter values.""" from PySide6.QtCore import QModelIndex, QPoint, Qt, Slot -from PySide6.QtWidgets import QWidget - +from PySide6.QtWidgets import QHeaderView, QWidget from spinedb_api import DateTime, Duration, ParameterValueFormatError from .array_value_editor import ArrayValueEditor from .indexed_value_table_context_menu import ArrayTableContextMenu @@ -49,7 +46,9 @@ def __init__(self, parent=None): self._ui.array_table_view.setModel(self._model) self._ui.array_table_view.setContextMenuPolicy(Qt.CustomContextMenu) self._ui.array_table_view.customContextMenuRequested.connect(self._show_table_context_menu) - self._ui.array_table_view.horizontalHeader().sectionDoubleClicked.connect(self._open_header_editor) + header = self._ui.array_table_view.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.sectionDoubleClicked.connect(self._open_header_editor) self._ui.value_type_combo_box.currentTextChanged.connect(self._change_value_type) delegate = ParameterValueElementDelegate(self._ui.array_table_view) delegate.value_editor_requested.connect(self.open_value_editor) diff --git a/spinetoolbox/widgets/array_value_editor.py b/spinetoolbox/widgets/array_value_editor.py index 6ce5258bb..2fc5c172f 100644 --- a/spinetoolbox/widgets/array_value_editor.py +++ b/spinetoolbox/widgets/array_value_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor dialog for Array elements. -""" +"""An editor dialog for Array elements.""" from PySide6.QtCore import Qt from .duration_editor import DurationEditor from .datetime_editor import DatetimeEditor diff --git a/spinetoolbox/widgets/code_text_edit.py b/spinetoolbox/widgets/code_text_edit.py index 4142cf59d..82e87b916 100644 --- a/spinetoolbox/widgets/code_text_edit.py +++ b/spinetoolbox/widgets/code_text_edit.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Provides simple text editor for programming purposes. -""" - +"""Provides simple text editor for programming purposes.""" from pygments.styles import get_style_by_name from pygments.lexers import get_lexer_by_name from pygments.util import ClassNotFound @@ -35,9 +33,9 @@ def __init__(self, *arg, **kwargs): self._right_margin = 16 font = QFontDatabase.systemFont(QFontDatabase.FixedFont) self.setFont(font) - foreground_color = self._style.styles[Token] + self.foreground_color = self._style.styles[Token] self.setStyleSheet( - f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {foreground_color};}}" + f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {self.foreground_color};}}" ) self.blockCountChanged.connect(self._update_line_number_area_width) self.updateRequest.connect(self._update_line_number_area) @@ -109,6 +107,14 @@ def _update_line_number_area_cursor_position(self): self._line_number_area.update(0, top, self._line_number_area.width(), bottom - top) self._cursor_block = new_cursor_block + def set_enabled_with_greyed(self, enabled): + super().setEnabled(enabled) + if enabled: + x = f"QPlainTextEdit {{background-color: {self._style.background_color}; color: {self.foreground_color};}}" + else: + x = f"QPlainTextEdit {{background-color: #737373; color: {self.foreground_color};}}" + self.setStyleSheet(x) + def resizeEvent(self, event): super().resizeEvent(event) rect = self.contentsRect() diff --git a/spinetoolbox/widgets/commit_dialog.py b/spinetoolbox/widgets/commit_dialog.py index 8b070771c..7b2e160e4 100644 --- a/spinetoolbox/widgets/commit_dialog.py +++ b/spinetoolbox/widgets/commit_dialog.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QDialogs to add edit and remove database items. -""" - +"""Classes for custom QDialogs to add edit and remove database items.""" from PySide6.QtWidgets import QDialog, QVBoxLayout, QPlainTextEdit, QDialogButtonBox, QApplication from PySide6.QtCore import Slot, Qt from PySide6.QtGui import QAction @@ -30,7 +28,7 @@ def __init__(self, parent, *db_names): super().__init__(parent) self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) self.setWindowModality(Qt.ApplicationModal) - self.setWindowTitle('Commit changes to {}'.format(",".join(db_names))) + self.setWindowTitle("Commit changes to {}".format(",".join(db_names))) form = QVBoxLayout(self) form.setContentsMargins(4, 4, 4, 4) self.action_accept = QAction(self) @@ -44,7 +42,7 @@ def __init__(self, parent, *db_names): self.commit_msg_edit.addAction(self.action_accept) button_box = QDialogButtonBox() button_box.addButton(QDialogButtonBox.StandardButton.Cancel) - self.commit_button = button_box.addButton('Commit', QDialogButtonBox.ButtonRole.AcceptRole) + self.commit_button = button_box.addButton("Commit", QDialogButtonBox.ButtonRole.AcceptRole) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) form.addWidget(self.commit_msg_edit) diff --git a/spinetoolbox/widgets/custom_combobox.py b/spinetoolbox/widgets/custom_combobox.py index cd3211307..377b3a0cf 100644 --- a/spinetoolbox/widgets/custom_combobox.py +++ b/spinetoolbox/widgets/custom_combobox.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a custom combo box for the custom open project dialog. -""" - +"""Contains custom combo box classes.""" import os from PySide6.QtCore import Qt from PySide6.QtWidgets import QComboBox, QStyle, QStylePainter, QStyleOptionComboBox, QDialog, QAbstractItemView @@ -20,6 +18,18 @@ from .notification import Notification +class CustomQComboBox(QComboBox): + """A custom QComboBox for showing kernels in Settings->Tools.""" + + def mouseMoveEvent(self, e): + """Catch mouseMoveEvent and accept it because the comboBox + popup (QListView) has mouse tracking on as default. + This makes sure the comboBox popup appears in correct + position and clicking on the combobox repeatedly does + not move the Settings window.""" + e.accept() + + class ElidedCombobox(QComboBox): """Combobox with elided text.""" @@ -28,7 +38,6 @@ def paintEvent(self, event): self.initStyleOption(opt) p = QStylePainter(self) p.drawComplexControl(QStyle.ComplexControl.CC_ComboBox, opt) - text_rect = self.style().subControlRect( QStyle.ComplexControl.CC_ComboBox, opt, QStyle.SubControl.SC_ComboBoxEditField, self ) diff --git a/spinetoolbox/widgets/custom_delegates.py b/spinetoolbox/widgets/custom_delegates.py index 11524df98..8f6463add 100644 --- a/spinetoolbox/widgets/custom_delegates.py +++ b/spinetoolbox/widgets/custom_delegates.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,11 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Custom item delegates. -""" - -from PySide6.QtCore import Qt, Signal, QEvent, QPoint, QRect +"""Custom item delegates.""" +from PySide6.QtCore import Qt, Signal, QEvent, QPoint, QRect, QModelIndex from PySide6.QtWidgets import ( QComboBox, QStyledItemDelegate, @@ -65,7 +63,7 @@ def _finalize_editing(self, editor): class CheckBoxDelegate(QStyledItemDelegate): """A delegate that places a fully functioning QCheckBox.""" - data_committed = Signal("QModelIndex", "QVariant") + data_committed = Signal(QModelIndex, object) def __init__(self, parent, centered=True): """ diff --git a/spinetoolbox/widgets/custom_menus.py b/spinetoolbox/widgets/custom_menus.py index 732af3003..133b469c8 100644 --- a/spinetoolbox/widgets/custom_menus.py +++ b/spinetoolbox/widgets/custom_menus.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom context menus and pop-up menus. -""" - +"""Classes for custom context menus and pop-up menus.""" import os from PySide6.QtWidgets import QMenu, QWidgetAction from PySide6.QtGui import QIcon, QAction @@ -238,7 +236,7 @@ def __init__(self, parent): """ super().__init__(parent) self._filter = None - self._remove_filter = QAction('Remove filters', None) + self._remove_filter = QAction("Remove filters", None) self._filter_action = QWidgetAction(self) self.addAction(self._remove_filter) @@ -278,7 +276,3 @@ def _change_filter(self): def emit_filter_changed(self, valid_values): raise NotImplementedError() - - def wipe_out(self): - self._filter._filter_model.set_list(set()) - self.deleteLater() diff --git a/spinetoolbox/widgets/custom_qgraphicsscene.py b/spinetoolbox/widgets/custom_qgraphicsscene.py index 97e7c6f5f..73966a3a1 100644 --- a/spinetoolbox/widgets/custom_qgraphicsscene.py +++ b/spinetoolbox/widgets/custom_qgraphicsscene.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Custom QGraphicsScene used in the Design View. -""" - +"""Custom QGraphicsScene used in the Design View.""" import math -from PySide6.QtCore import Qt, Signal, Slot, QItemSelectionModel, QPointF, QEvent, QTimer +from PySide6.QtCore import Qt, Signal, Slot, QPointF, QEvent from PySide6.QtWidgets import QGraphicsItem, QGraphicsScene from PySide6.QtGui import QColor, QPen, QBrush from ..project_item_icon import ProjectItemIcon @@ -46,6 +44,9 @@ class DesignGraphicsScene(CustomGraphicsScene): Mainly, it handles drag and drop events of ProjectItemDragMixin sources. """ + link_about_to_be_drawn = Signal() + link_drawing_finished = Signal() + def __init__(self, parent, toolbox): """ Args: @@ -67,20 +68,9 @@ def __init__(self, parent, toolbox): self._jump_drawer.hide() self.link_drawer = None self.icon_group = set() # Group of project item icons that are moving together - self.dirty_links = set() - self._timer = QTimer(self) - self._timer.setInterval(5) - self._timer.timeout.connect(self._handle_timeout) - self._timer.start() self._cat = Cat(self) self.connect_signals() - @Slot() - def _handle_timeout(self): - for link in self.dirty_links: - link.update_geometry() - self.dirty_links.clear() - def clear_icons_and_links(self): for item in self.items(): if isinstance(item, (Link, JumpLink, ProjectItemIcon)): @@ -95,26 +85,36 @@ def mouseMoveEvent(self, event): super().mouseMoveEvent(event) def mousePressEvent(self, event): - """Puts link drawer to sleep and log message if it looks like the user doesn't know what they're doing.""" - if ( - self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "false" - and self._finish_link() - ): - return + """Puts link drawer to sleep and logs message if it looks like the user doesn't know what they're doing.""" + if self.link_drawer is not None: + if event.button() == Qt.MouseButton.RightButton: + return + if ( + self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "false" + and event.button() == Qt.MouseButton.LeftButton + and self._finish_link() + ): + event.accept() + return super().mousePressEvent(event) def mouseReleaseEvent(self, event): - """Makes link if drawer is released over a valid connector button.""" - if ( - self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "true" - and self._finish_link() - ): - return + """Makes link if drawer is released over a valid connector button or cancel link drawing on right button.""" + if self.link_drawer is not None: + if event.button() == Qt.MouseButton.RightButton: + self.link_drawer.sleep() + event.accept() + return + if ( + self._toolbox.qsettings().value("appSettings/dragToDrawLinks", defaultValue="false") == "true" + and event.button() == Qt.MouseButton.LeftButton + and self._finish_link() + ): + event.accept() + return super().mouseReleaseEvent(event) def _finish_link(self): - if self.link_drawer is None: - return False if self.link_drawer.src_connector.isUnderMouse(): self.link_drawer.sleep() return False @@ -122,6 +122,9 @@ def _finish_link(self): self.link_drawer.sleep() self.emit_connection_failed() return False + if self.link_drawer.src_connector == self.link_drawer.dst_connector: + self.link_drawer.sleep() + return False self.link_drawer.dst_connector.set_normal_brush() self.link_drawer.add_link() return True @@ -146,7 +149,7 @@ def project_item_icons(self): @Slot() def handle_selection_changed(self): - """Synchronizes selection with the project tree.""" + """Activates items or links based on currently selected items (or links).""" selected_items = set(self.selectedItems()) if self._last_selected_items == selected_items: return @@ -160,23 +163,10 @@ def handle_selection_changed(self): links.append(item) # Set active project item and active link in toolbox active_project_item = ( - self._toolbox.project_item_model.get_item(project_item_icons[0].name()).project_item - if len(project_item_icons) == 1 - else None + self._toolbox.project().get_item(project_item_icons[0].name()) if len(project_item_icons) == 1 else None ) active_link_item = links[0].item if len(links) == 1 else None - # Sync selection with project tree view selected_item_names = {icon.name() for icon in project_item_icons} - self._toolbox.sync_item_selection_with_scene = False - for ind in self._toolbox.project_item_model.leaf_indexes(): - item_name = self._toolbox.project_item_model.item(ind).name - cmd = QItemSelectionModel.Select if item_name in selected_item_names else QItemSelectionModel.Deselect - self._toolbox.ui.treeView_project.selectionModel().select(ind, cmd) - self._toolbox.sync_item_selection_with_scene = True - # Make last item selected the current index in project tree view - if project_item_icons: - last_ind = self._toolbox.project_item_model.find_item(project_item_icons[-1].name()) - self._toolbox.ui.treeView_project.selectionModel().setCurrentIndex(last_ind, QItemSelectionModel.NoUpdate) selected_link_icons = [conn.parent for link in links for conn in (link.src_connector, link.dst_connector)] selected_item_names |= set(icon.name() for icon in selected_link_icons) self._toolbox.refresh_active_elements(active_project_item, active_link_item, selected_item_names) @@ -199,7 +189,7 @@ def set_bg_choice(self, bg_choice): self.bg_choice = bg_choice def dragLeaveEvent(self, event): - """Accept event.""" + """Accepts event.""" event.accept() def dragEnterEvent(self, event): diff --git a/spinetoolbox/widgets/custom_qgraphicsviews.py b/spinetoolbox/widgets/custom_qgraphicsviews.py index 7bd1b68a8..7a85d30e7 100644 --- a/spinetoolbox/widgets/custom_qgraphicsviews.py +++ b/spinetoolbox/widgets/custom_qgraphicsviews.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QGraphicsViews for the Design and Graph views. -""" - +"""Classes for custom QGraphicsViews for the Design and Graph views.""" import math from PySide6.QtWidgets import QGraphicsView, QGraphicsItem, QGraphicsRectItem -from PySide6.QtGui import QCursor -from PySide6.QtCore import Slot, Qt, QTimeLine, QRectF -from spine_engine.project_item.connection import Connection +from PySide6.QtGui import QContextMenuEvent, QCursor, QMouseEvent +from PySide6.QtCore import QTimer, Slot, Qt, QTimeLine, QRectF from ..project_item_icon import ProjectItemIcon from ..project_commands import AddConnectionCommand, AddJumpCommand, RemoveConnectionsCommand, RemoveJumpsCommand from ..link import Link, JumpLink @@ -26,14 +23,15 @@ class CustomQGraphicsView(QGraphicsView): - """Super class for Design and Entity QGraphicsViews. + """Super class for Design and Entity QGraphicsViews.""" - Attributes: - parent (QWidget): Parent widget - """ + DRAG_MIN_DURATION = 150 def __init__(self, parent): - """Init CustomQGraphicsView.""" + """ + Args: + parent (QWidget): parent widget + """ super().__init__(parent=parent) self._zoom_factor_base = 1.0015 self._angle = 120 @@ -42,6 +40,9 @@ def __init__(self, parent): self._items_fitting_zoom = 1.0 self._max_zoom = 10.0 self._min_zoom = 0.1 + self._previous_mouse_pos = None + self._last_right_mouse_press = None + self._enabled_context_menu_policy = self.contextMenuPolicy() @property def _qsettings(self): @@ -59,11 +60,10 @@ def reset_zoom(self): self._set_preferred_scene_rect() def keyPressEvent(self, event): - """Overridden method. Enable zooming with plus and minus keys (comma resets zoom). - Send event downstream to QGraphicsItems if pressed key is not handled here. + """Enables zooming with plus and minus keys (comma resets zoom). Args: - event (QKeyEvent): Pressed key + event (QKeyEvent): key press event """ if event.key() == Qt.Key_Plus: self.zoom_in() @@ -75,32 +75,93 @@ def keyPressEvent(self, event): super().keyPressEvent(event) def mousePressEvent(self, event): - """Set rubber band selection mode if Control pressed. - Enable resetting the zoom factor from the middle mouse button. + """Sets rubber band selection mode if Control or right mouse button is pressed. + Enables resetting the zoom factor from the middle mouse button. """ + self._previous_mouse_pos = event.position().toPoint() item = self.itemAt(event.position().toPoint()) if not item or not item.acceptedMouseButtons() & event.buttons(): - if event.modifiers() & Qt.ControlModifier: - self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + button = event.button() + if button == Qt.MouseButton.LeftButton: self.viewport().setCursor(Qt.CrossCursor) - if event.button() == Qt.MiddleButton: + elif button == Qt.MouseButton.MiddleButton: self.reset_zoom() + if button == Qt.MouseButton.RightButton: + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + self._last_right_mouse_press = event.timestamp() + event = _fake_left_button_event(event) super().mousePressEvent(event) + def mouseMoveEvent(self, event): + if ( + event.buttons() & Qt.MouseButton.RightButton == Qt.MouseButton.RightButton + and self.dragMode() == QGraphicsView.DragMode.ScrollHandDrag + and self._drag_duration_passed(event) + ): + if self._previous_mouse_pos is not None: + delta = event.position().toPoint() - self._previous_mouse_pos + self._scroll_scene_by(delta.x(), delta.y()) + self._previous_mouse_pos = event.position().toPoint() + event = _fake_left_button_event(event) + super().mouseMoveEvent(event) + def mouseReleaseEvent(self, event): """Reestablish scroll hand drag mode.""" + context_menu_disabled = False + if self.dragMode() == QGraphicsView.DragMode.ScrollHandDrag: + if self._drag_duration_passed(event): + context_menu_disabled = True + self.disable_context_menu() + else: + self.contextMenuEvent( + QContextMenuEvent(QContextMenuEvent.Reason.Mouse, event.pos(), event.globalPos(), event.modifiers()) + ) + event = _fake_left_button_event(event) super().mouseReleaseEvent(event) + self.setDragMode(QGraphicsView.DragMode.RubberBandDrag) + self._previous_mouse_pos = None + self._last_right_mouse_press = None + if context_menu_disabled: + self.enable_context_menu() item = next(iter([x for x in self.items(event.position().toPoint()) if x.hasCursor()]), None) - was_not_rubber_band_drag = self.dragMode() != QGraphicsView.DragMode.RubberBandDrag - self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) - if item and was_not_rubber_band_drag: + if item: self.viewport().setCursor(item.cursor()) else: self.viewport().setCursor(Qt.ArrowCursor) + def _scroll_scene_by(self, dx, dy): + if dx == dy == 0: + return + scene_rect = self.sceneRect() + view_scene_rect = self.mapFromScene(scene_rect).boundingRect() + view_rect = self.viewport().rect() + scene_dx = abs((self.mapToScene(0, 0) - self.mapToScene(dx, 0)).x()) + scene_dy = abs((self.mapToScene(0, 0) - self.mapToScene(0, dy)).y()) + if dx < 0 and view_rect.right() - dx >= view_scene_rect.right(): + scene_rect.adjust(0, 0, scene_dx, 0) + elif dx > 0 and view_rect.left() - dx <= view_scene_rect.left(): + scene_rect.adjust(-scene_dx, 0, 0, 0) + if dy < 0 and view_rect.bottom() - dy >= view_scene_rect.bottom(): + scene_rect.adjust(0, 0, 0, scene_dy) + elif dy > 0 and view_rect.top() - dy <= view_scene_rect.top(): + scene_rect.adjust(0, -scene_dy, 0, 0) + self.scene().setSceneRect(scene_rect) + def _use_smooth_zoom(self): return self._qsettings.value("appSettings/smoothZoom", defaultValue="false") == "true" + def _drag_duration_passed(self, mouse_event): + """Test is drag duration has passed. + + Args: + mouse_event (QMouseEvent): current mouse event + """ + return ( + mouse_event.timestamp() - self._last_right_mouse_press > self.DRAG_MIN_DURATION + if self._last_right_mouse_press is not None + else False + ) + def wheelEvent(self, event): """Zooms in/out. @@ -157,7 +218,7 @@ def setScene(self, scene): Sets a new scene to this view. Args: - scene (ShrinkingScene): a new scene + scene (DesignGraphicsScene): a new scene """ super().setScene(scene) scene.item_move_finished.connect(self._handle_item_move_finished) @@ -276,6 +337,19 @@ def _set_preferred_scene_rect(self): items_scene_rect = self.scene().itemsBoundingRect() self.scene().setSceneRect(viewport_scene_rect.united(items_scene_rect)) + @Slot() + def disable_context_menu(self): + """Disables the context menu.""" + self._enabled_context_menu_policy = self.contextMenuPolicy() + self.setContextMenuPolicy(Qt.ContextMenuPolicy.PreventContextMenu) + + @Slot() + def enable_context_menu(self): + """Enables the context menu.""" + # We use timer here to delay setting the policy. + # Otherwise, using right-click to cancel link drawing would still open the context menu. + QTimer.singleShot(0, lambda: self.setContextMenuPolicy(self._enabled_context_menu_policy)) + class DesignQGraphicsView(CustomQGraphicsView): """QGraphicsView for the Design View.""" @@ -285,7 +359,7 @@ def __init__(self, parent): Args: parent (QWidget): parent widget """ - super().__init__(parent=parent) # Parent is passed to QWidget's constructor + super().__init__(parent=parent) self._toolbox = None @property @@ -295,7 +369,10 @@ def _qsettings(self): def set_ui(self, toolbox): """Set a new scene into the Design View when app is started.""" self._toolbox = toolbox - self.setScene(DesignGraphicsScene(self, toolbox)) + scene = DesignGraphicsScene(self, toolbox) + scene.link_about_to_be_drawn.connect(self.disable_context_menu) + scene.link_drawing_finished.connect(self.enable_context_menu) + self.setScene(scene) def reset_zoom(self): super().reset_zoom() @@ -502,3 +579,21 @@ def contextMenuEvent(self, event): event.accept() global_pos = self.viewport().mapToGlobal(event.pos()) self._toolbox.show_project_or_item_context_menu(global_pos, None) + + +def _fake_left_button_event(mouse_event): + """Makes a left-click mouse event that is otherwise close of given event. + + Args: + mouse_event (QMouseEvent): mouse event + + Returns: + QMouseEvent: left-click mouse event + """ + return QMouseEvent( + mouse_event.type(), + mouse_event.pos(), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) diff --git a/spinetoolbox/widgets/custom_qlineedits.py b/spinetoolbox/widgets/custom_qlineedits.py index 5ffb729af..fdc6979d1 100644 --- a/spinetoolbox/widgets/custom_qlineedits.py +++ b/spinetoolbox/widgets/custom_qlineedits.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom line edits. -""" - +"""Contains a custom line edit.""" from PySide6.QtWidgets import QLineEdit from .custom_qwidgets import UndoRedoMixin diff --git a/spinetoolbox/widgets/custom_qtableview.py b/spinetoolbox/widgets/custom_qtableview.py index f646574d3..a26a67fa2 100644 --- a/spinetoolbox/widgets/custom_qtableview.py +++ b/spinetoolbox/widgets/custom_qtableview.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Custom QTableView classes that support copy-paste and the like. -""" - +"""Custom QTableView classes that support copy-paste and the like.""" import csv import ctypes import io @@ -21,7 +19,6 @@ from numbers import Number import re from operator import methodcaller - from PySide6.QtWidgets import QTableView, QApplication from PySide6.QtCore import Qt, Slot, QItemSelection, QItemSelectionModel, QPoint from PySide6.QtGui import QKeySequence, QIcon, QAction @@ -51,6 +48,7 @@ def __init__(self, parent=None): self._delete_action = QAction("Delete", self) self._delete_action.setShortcut(QKeySequence.Delete) self.addAction(self._delete_action) + self._pasted_data_converters = {} self._delete_action.triggered.connect(self.delete_content) def init_copy_and_paste_actions(self): @@ -124,11 +122,17 @@ def copy(self, _=False): continue data = self.model().index(i, j).data(Qt.ItemDataRole.EditRole) if data is not None: - try: - number = float(data) - str_data = locale.str(number) - except ValueError: - str_data = str(data) + if isinstance(data, bool): + str_data = "true" if data else "false" + else: + if isinstance(data, int): + str_data = str(data) + else: + try: + number = float(data) + str_data = locale.str(number) + except ValueError: + str_data = str(data) else: str_data = "" row.append(str_data) @@ -204,6 +208,7 @@ def paste_on_selection(self): rows = [x for r in selection for x in range(r.top(), r.bottom() + 1) if not is_row_hidden(x)] is_column_hidden = self.horizontalHeader().isSectionHidden columns = [x for r in selection for x in range(r.left(), r.right() + 1) if not is_column_hidden(x)] + converters = self._converters() if self._pasted_data_converters else {} model_index = self.model().index for row in rows: for column in columns: @@ -213,6 +218,11 @@ def paste_on_selection(self): j = (column - columns[0]) % len(data[i]) value = data[i][j] indexes.append(index) + if converters: + convert = converters.get(column) + if convert is not None: + values.append(convert(value)) + continue values.append(value) self.model().batch_set_data(indexes, values) return True @@ -252,15 +262,17 @@ def paste_normal(self): visual_column += 1 # Insert extra rows if needed: last_row = max(rows) - row_count = self.model().rowCount() + model = self.model() + row_count = model.rowCount() if last_row >= row_count: - self.model().insertRows(row_count, last_row - row_count + 1) + model.insertRows(row_count, last_row - row_count + 1) # Insert extra columns if needed: last_column = max(columns) - column_count = self.model().columnCount() + column_count = model.columnCount() if last_column >= column_count: - self.model().insertColumns(column_count, last_column - column_count + 1) - model_index = self.model().index + model.insertColumns(column_count, last_column - column_count + 1) + converters = self._converters() if self._pasted_data_converters else {} + model_index = model.index for i, row in enumerate(rows): try: line = data[i] @@ -274,10 +286,28 @@ def paste_normal(self): index = model_index(row, column) if index.flags() & Qt.ItemIsEditable: indexes.append(index) + if converters: + convert = converters.get(column) + if convert is not None: + values.append(convert(value)) + continue values.append(value) - self.model().batch_set_data(indexes, values) + model.batch_set_data(indexes, values) return True + def set_column_converter_for_pasting(self, header, converter): + self._pasted_data_converters[header] = converter + + def _converters(self): + converters = {} + model = self.model() + for column in range(model.columnCount()): + label = model.headerData(column, Qt.Orientation.Horizontal) + converter = self._pasted_data_converters.get(label) + if converter is not None: + converters[column] = converter + return converters + class AutoFilterCopyPasteTableView(CopyPasteTableView): """Custom QTableView class with autofilter functionality.""" @@ -289,7 +319,7 @@ def __init__(self, parent): """ super().__init__(parent=parent) self._show_filter_menu_action = QAction(self) - self._show_filter_menu_action.setShortcut(Qt.ALT | Qt.Key_Down) + self._show_filter_menu_action.setShortcut(QKeySequence(Qt.Modifier.ALT.value | Qt.Key.Key_Down.value)) self._show_filter_menu_action.setShortcutContext(Qt.WidgetShortcut) self._show_filter_menu_action.triggered.connect(self._trigger_filter_menu) self.addAction(self._show_filter_menu_action) @@ -358,7 +388,7 @@ def copy(self, _=False): else: data_values[row - row_first] = data with io.StringIO() as output: - writer = csv.writer(output, delimiter='\t') + writer = csv.writer(output, delimiter="\t") with system_lc_numeric(): if all(stamp is None for stamp in data_indexes): for value in data_values: @@ -397,7 +427,7 @@ def paste(self, _=True): clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() - if 'text/plain' not in data_formats: + if "text/plain" not in data_formats: return False try: pasted_table = self._read_pasted_text(clipboard.text()) @@ -437,7 +467,7 @@ def _read_pasted_text(text): list of float: A list of floats """ with io.StringIO(text) as input_stream: - reader = csv.reader(input_stream, delimiter='\t') + reader = csv.reader(input_stream, delimiter="\t") with system_lc_numeric(): return [locale.atof(row[0]) for row in reader if row] @@ -474,7 +504,7 @@ def paste(self, _=False): clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() - if 'text/plain' not in data_formats: + if "text/plain" not in data_formats: return False try: pasted_table = self._read_pasted_text(clipboard.text()) @@ -571,7 +601,7 @@ def _read_pasted_text(text): tuple: a tuple (data indexes, data values) """ with io.StringIO(text) as input_stream: - reader = csv.reader(input_stream, delimiter='\t') + reader = csv.reader(input_stream, delimiter="\t") single_column = list() data_indexes = list() data_values = list() @@ -620,7 +650,7 @@ def copy(self, _=False): row_values["x" if index.column() == 0 else "y"] = index.data(Qt.ItemDataRole.EditRole) with system_lc_numeric(): with io.StringIO() as output: - writer = csv.writer(output, delimiter='\t') + writer = csv.writer(output, delimiter="\t") for row_values in values.values(): x = row_values.get("x", "") y = row_values.get("y", "") @@ -630,7 +660,7 @@ def copy(self, _=False): else: with system_lc_numeric(): with io.StringIO() as output: - writer = csv.writer(output, delimiter='\t') + writer = csv.writer(output, delimiter="\t") for index in selected_indexes: y = index.data(Qt.ItemDataRole.EditRole) writer.writerow([locale.str(y) if isinstance(y, Number) else y]) @@ -646,7 +676,7 @@ def paste(self, _=False): clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() - if 'text/plain' not in data_formats: + if "text/plain" not in data_formats: return False try: pasted_table = self._read_pasted_text(clipboard.text()) @@ -691,7 +721,7 @@ def _read_pasted_text(text): list of str: data column """ with io.StringIO(text) as input_stream: - reader = csv.reader(input_stream, delimiter='\t') + reader = csv.reader(input_stream, delimiter="\t") column = [row[0] for row in reader if row] return column @@ -766,7 +796,7 @@ def paste(self, _=False): clipboard = QApplication.clipboard() mime_data = clipboard.mimeData() data_formats = mime_data.formats() - if 'text/plain' not in data_formats: + if "text/plain" not in data_formats: return False pasted_table = self._read_pasted_text(clipboard.text()) paste_length = len(pasted_table) @@ -811,7 +841,7 @@ def _read_pasted_text(text): """ data = list() with io.StringIO(text) as input_stream: - reader = csv.reader(input_stream, delimiter='\t') + reader = csv.reader(input_stream, delimiter="\t") with system_lc_numeric(): for row in reader: data_row = list() diff --git a/spinetoolbox/widgets/custom_qtextbrowser.py b/spinetoolbox/widgets/custom_qtextbrowser.py index f2c589ea2..0505fa04c 100644 --- a/spinetoolbox/widgets/custom_qtextbrowser.py +++ b/spinetoolbox/widgets/custom_qtextbrowser.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Class for a custom QTextBrowser for showing the logs and tool output. -""" - +"""Class for a custom QTextBrowser for showing the logs and tool output.""" from contextlib import contextmanager from PySide6.QtCore import Slot from PySide6.QtGui import QTextCursor, QFontDatabase, QTextBlockFormat, QTextFrameFormat, QBrush, QAction, QPalette @@ -124,9 +122,9 @@ def _select_execution(self, action): @staticmethod def _make_log_entry_title(title): - return f'{title}' + return f"{title}" - def start_execution(self, timestamp): + def make_log_entry_point(self, timestamp): """Creates cursors (log entry points) for given items in event log. Args: diff --git a/spinetoolbox/widgets/custom_qtreeview.py b/spinetoolbox/widgets/custom_qtreeview.py index 72f974882..178b609b4 100644 --- a/spinetoolbox/widgets/custom_qtreeview.py +++ b/spinetoolbox/widgets/custom_qtreeview.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QTreeView. -""" - +"""Classes for custom QTreeView.""" import os from PySide6.QtWidgets import QTreeView, QApplication from PySide6.QtCore import Signal, Qt diff --git a/spinetoolbox/widgets/custom_qwidgets.py b/spinetoolbox/widgets/custom_qwidgets.py index 469c1ea76..4cc93c7bb 100644 --- a/spinetoolbox/widgets/custom_qwidgets.py +++ b/spinetoolbox/widgets/custom_qwidgets.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Custom QWidgets for Filtering and Zooming. -""" - +"""Custom QWidgets for Filtering and Zooming.""" from PySide6.QtWidgets import ( QWidget, QVBoxLayout, @@ -119,13 +117,13 @@ def __init__(self, parent, make_filter_model, *args, **kwargs): # parameters self._filter_state = set() self._filter_empty_state = None - self._search_text = '' + self._search_text = "" self.search_delay = 200 # create ui elements self._ui_vertical_layout = QVBoxLayout(self) self._ui_list = QListView() self._ui_edit = QLineEdit() - self._ui_edit.setPlaceholderText('Search') + self._ui_edit.setPlaceholderText("Search") self._ui_edit.setClearButtonEnabled(True) self._ui_buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) self._ui_vertical_layout.addWidget(self._ui_edit) @@ -134,6 +132,7 @@ def __init__(self, parent, make_filter_model, *args, **kwargs): self._search_timer = QTimer() # Used to limit search so it doesn't search when typing self._search_timer.setSingleShot(True) self._filter_model = make_filter_model(*args, **kwargs) + self._filter_model.setParent(self) self._filter_model.set_list(self._filter_state) self._ui_list.setModel(self._filter_model) self.connect_signals() @@ -173,14 +172,14 @@ def _apply_filter(self): """Apply current filter and save state.""" self._filter_model.apply_filter() self.save_state() - self._ui_edit.setText('') + self._ui_edit.setText("") self.okPressed.emit() def _cancel_filter(self): """Cancel current edit of filter and set the state to the stored state.""" self._filter_model.remove_filter() self.reset_state() - self._ui_edit.setText('') + self._ui_edit.setText("") self.cancelPressed.emit() def _filter_list(self): @@ -345,7 +344,7 @@ def _align_buttons(self): for i in range(layout.count()): item = layout.itemAt(i) if item.widget() in self._buttons: - layout.itemAt(i).setAlignment(Qt.AlignBottom) + item.setAlignment(Qt.AlignBottom) def add_frame(self, left, right, title): """Add frame around given actions, with given title. @@ -383,7 +382,7 @@ def sizeHint(self): """Make room for frames if needed.""" size = super().sizeHint() if self._frames: - size = QSize(size.width(), size.height() + self.fontMetrics().height()) + size.setHeight(size.height() + self.fontMetrics().height()) return size def paintEvent(self, ev): @@ -543,7 +542,7 @@ class _ExecutionManager: def __set_name__(self, owner, name): self.public_name = name - self.private_name = '_' + name + self.private_name = "_" + name def __get__(self, obj, objtype=None): return getattr(obj, self.private_name) @@ -634,7 +633,7 @@ def __init__(self, text="", parent=None): font = QFontDatabase.systemFont(QFontDatabase.FixedFont) line_edit.setFont(font) button = QToolButton() - font = QFont('Font Awesome 5 Free Solid') + font = QFont("Font Awesome 5 Free Solid") button.setFont(font) button.setText("\uf0c5") button.setToolTip("Copy text") @@ -676,6 +675,12 @@ def setMinimum(self, minimum): except TypeError: pass + def setMaximum(self, maximum): + try: + self._validator.setTop(maximum) + except TypeError: + pass + @Slot(str) def setValue(self, value, strict=False): try: @@ -764,19 +769,3 @@ def _handle_check_box_state_changed(self, _checked): class PurgeSettingsDialog(SelectDatabaseItemsDialog): _ok_button_can_be_disabled = False - - -class ResizingViewMixin: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._resizing_timer = QTimer() - self._resizing_timer.setSingleShot(True) - self._resizing_timer.setInterval(20) - self._resizing_timer.timeout.connect(self._do_resize) - - def rowsInserted(self, parent, start, end): - super().rowsInserted(parent, start, end) - self._resizing_timer.start() - - def _do_resize(self): - raise NotImplementedError() diff --git a/spinetoolbox/widgets/datetime_editor.py b/spinetoolbox/widgets/datetime_editor.py index 99cf0bf51..9118b80f5 100644 --- a/spinetoolbox/widgets/datetime_editor.py +++ b/spinetoolbox/widgets/datetime_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor widget for editing datetime database (relationship) parameter values. -""" - +"""An editor widget for editing datetime database (relationship) parameter values.""" from datetime import datetime from PySide6.QtCore import QDate, QDateTime, QTime, Slot from PySide6.QtWidgets import QWidget diff --git a/spinetoolbox/widgets/duration_editor.py b/spinetoolbox/widgets/duration_editor.py index 0dbd662bd..8e13e8b93 100644 --- a/spinetoolbox/widgets/duration_editor.py +++ b/spinetoolbox/widgets/duration_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor widget for editing duration database (relationship) parameter values. -""" - +"""An editor widget for editing duration database (relationship) parameter values.""" from PySide6.QtCore import Slot from PySide6.QtWidgets import QWidget from spinedb_api import Duration, duration_to_relativedelta, ParameterValueFormatError diff --git a/spinetoolbox/widgets/indexed_value_table_context_menu.py b/spinetoolbox/widgets/indexed_value_table_context_menu.py index 514c021a0..000adc432 100644 --- a/spinetoolbox/widgets/indexed_value_table_context_menu.py +++ b/spinetoolbox/widgets/indexed_value_table_context_menu.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,11 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Context menus for parameter value editor widgets. -""" +"""Context menus for parameter value editor widgets.""" from operator import itemgetter - from PySide6.QtCore import Slot from PySide6.QtWidgets import QInputDialog, QMenu from PySide6.QtGui import QAction diff --git a/spinetoolbox/widgets/install_julia_wizard.py b/spinetoolbox/widgets/install_julia_wizard.py index 378c6b61f..5344b56a5 100644 --- a/spinetoolbox/widgets/install_julia_wizard.py +++ b/spinetoolbox/widgets/install_julia_wizard.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QDialogs for julia setup. -""" - +"""Classes for custom QDialogs for julia setup.""" import os from enum import IntEnum, auto @@ -34,7 +32,7 @@ ) from PySide6.QtCore import Signal, Slot, Qt from PySide6.QtGui import QCursor -from spine_engine.utils.helpers import resolve_python_interpreter +from spine_engine.utils.helpers import resolve_current_python_interpreter from ..execution_managers import QProcessExecutionManager from ..config import APPLICATION_PATH from .custom_qwidgets import HyperTextLabel, QWizardProcessPage, LabelWithCopyButton @@ -74,12 +72,12 @@ def __init__(self, parent): def set_julia_exe(self): basename = next( - (file for file in os.listdir(self.field('symlink_dir')) if file.lower().startswith("julia")), None + (file for file in os.listdir(self.field("symlink_dir")) if file.lower().startswith("julia")), None ) if basename is None: self.julia_exe = None return - self.julia_exe = os.path.join(self.field('symlink_dir'), basename) + self.julia_exe = os.path.join(self.field("symlink_dir"), basename) def accept(self): super().accept() @@ -91,7 +89,7 @@ class JillNotFoundPage(QWizardPage): def __init__(self, parent): super().__init__(parent) self.setTitle("Unable to find jill") - conda_env = os.environ.get('CONDA_DEFAULT_ENV', 'base') + conda_env = os.environ.get("CONDA_DEFAULT_ENV", "base") toolbox_dir = os.path.dirname(APPLICATION_PATH) header = ( "

Spine Toolbox needs the jill package " @@ -210,7 +208,7 @@ def initializePage(self): # 1. sys.executable when not frozen # 2. PATH python if frozen (This fails if no jill installed) # 3. If no PATH python, uses embedded python /tools/python.exe - python = resolve_python_interpreter("") + python = resolve_current_python_interpreter() self._exec_mngr = QProcessExecutionManager(self, python, args, semisilent=True) self.completeChanged.emit() self._exec_mngr.execution_finished.connect(self._handle_julia_install_finished) diff --git a/spinetoolbox/widgets/jump_properties_widget.py b/spinetoolbox/widgets/jump_properties_widget.py index 4fd82b8f6..cd9d7faa2 100644 --- a/spinetoolbox/widgets/jump_properties_widget.py +++ b/spinetoolbox/widgets/jump_properties_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains jump properties widget's business logic. -""" +"""Contains jump properties widget's business logic.""" from PySide6.QtCore import Slot, QItemSelection from .properties_widget import PropertiesWidgetBase from ..project_commands import SetJumpConditionCommand, UpdateJumpCmdLineArgsCommand @@ -57,25 +56,28 @@ def __init__(self, toolbox, base_color=None): def _load_condition_into_ui(self, condition): self._track_changes = False self._ui.pushButton_save_script.setEnabled(False) - self._ui.condition_script_edit.setEnabled(condition["type"] == "python-script") + self._ui.condition_script_edit.set_enabled_with_greyed(condition["type"] == "python-script") self._ui.comboBox_tool_spec.setEnabled(condition["type"] == "tool-specification") self._ui.toolButton_edit_tool_spec.setEnabled(condition["type"] == "tool-specification") - if not condition["type"] == "python-script": - self._ui.condition_script_edit.clear() if condition["type"] == "python-script": self._ui.radioButton_py_script.setChecked(True) - if not condition["script"] or condition["script"] != self._ui.condition_script_edit.toPlainText(): - self._ui.condition_script_edit.setPlainText(condition["script"]) + self._ui.condition_script_edit.setPlainText(condition["script"]) elif condition["type"] == "tool-specification": self._ui.radioButton_tool_spec.setChecked(True) self._ui.comboBox_tool_spec.setCurrentText(condition["specification"]) self._track_changes = True def _make_condition_from_ui(self): + condition = { + "script": self._ui.condition_script_edit.toPlainText(), + "specification": self._ui.comboBox_tool_spec.currentText(), + } if self._ui.radioButton_py_script.isChecked(): - return {"type": "python-script", "script": self._ui.condition_script_edit.toPlainText()} + condition["type"] = "python-script" + return condition if self._ui.radioButton_tool_spec.isChecked(): - return {"type": "tool-specification", "specification": self._ui.comboBox_tool_spec.currentText()} + condition["type"] = "tool-specification" + return condition return {} def _change_condition(self): @@ -84,7 +86,7 @@ def _change_condition(self): return condition = self._make_condition_from_ui() if self._jump.condition != condition: - self._toolbox.undo_stack.push(SetJumpConditionCommand(self, self._jump, condition)) + self._toolbox.undo_stack.push(SetJumpConditionCommand(self._toolbox.project(), self._jump, self, condition)) @Slot(bool) def _show_tool_spec_form(self, _checked=False): @@ -121,7 +123,9 @@ def _populate_cmd_line_args_model(self): @Slot(list) def _push_update_cmd_line_args_command(self, cmd_line_args): if self._jump.cmd_line_args != cmd_line_args: - self._toolbox.undo_stack.push(UpdateJumpCmdLineArgsCommand(self, self._jump, cmd_line_args)) + self._toolbox.undo_stack.push( + UpdateJumpCmdLineArgsCommand(self._toolbox.project(), self._jump, self, cmd_line_args) + ) @Slot(bool) def _remove_arg(self, _=False): diff --git a/spinetoolbox/widgets/jupyter_console_widget.py b/spinetoolbox/widgets/jupyter_console_widget.py index ce132de5c..58db5f58e 100644 --- a/spinetoolbox/widgets/jupyter_console_widget.py +++ b/spinetoolbox/widgets/jupyter_console_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Class for a custom RichJupyterWidget that can run Tool instances. -""" - +"""Class for a custom RichJupyterWidget that can run Tool instances.""" import logging import multiprocessing from queue import Empty @@ -52,7 +50,10 @@ def __init__(self, toolbox, kernel_name, owner=None): super().__init__() self._toolbox = toolbox self.kernel_name = kernel_name + self.sysimage_path = None self.owners = {owner} + if owner is not None: + self.sysimage_path = owner._options.get("julia_sysimage", None) self.kernel_client = None self._connection_file = None self._execution_manager = None @@ -61,7 +62,7 @@ def __init__(self, toolbox, kernel_name, owner=None): self._q = multiprocessing.Queue() self._logger = QueueLogger(self._q, "DetachedPythonConsole", None, dict()) self.normal_cursor = self._control.viewport().cursor() - self._copy_input_action = QAction('Copy (Only Input)', self) + self._copy_input_action = QAction("Copy (Only Input)", self) self._copy_input_action.triggered.connect(lambda checked: self.copy_input()) self._copy_input_action.setEnabled(False) self.copy_available.connect(self._copy_input_action.setEnabled) @@ -90,6 +91,7 @@ def request_start_kernel(self, conda=False): server_ip="127.0.0.1", environment=environment, conda_exe=conda_exe, + extra_switches=self.sysimage_path, ) try: msg_type, msg = self._q.get(timeout=20) # Blocks until msg (tuple(str, dict) is received, or timeout. @@ -250,7 +252,7 @@ def _context_menu_make(self, pos): """Reimplemented to add actions to console context-menus.""" menu = super()._context_menu_make(pos) for before_action in menu.actions(): - if before_action.text() == 'Copy (Raw Text)': + if before_action.text() == "Copy (Raw Text)": menu.insertAction(before_action, self._copy_input_action) break first_action = menu.actions()[0] @@ -277,15 +279,19 @@ def copy_input(self): if m: useful_lines.append(line[len(m.group(0)) :]) continue - text = '\n'.join(useful_lines) + text = "\n".join(useful_lines) try: - was_newline = text[-1] == '\n' + was_newline = text[-1] == "\n" except IndexError: was_newline = False if was_newline: # user doesn't need newline text = text[:-1] QApplication.clipboard().setText(text) + def _show_interpreter_prompt(self, number=None): + if self.kernel_client is not None: + super()._show_interpreter_prompt(number) + def closeEvent(self, e): """Catches close event to shut down the kernel client and sends a signal to Toolbox to request Spine Engine diff --git a/spinetoolbox/widgets/kernel_editor.py b/spinetoolbox/widgets/kernel_editor.py index 4d440ff93..4bfb628df 100644 --- a/spinetoolbox/widgets/kernel_editor.py +++ b/spinetoolbox/widgets/kernel_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,13 @@ # this program. If not, see . ###################################################################################################################### -""" -Widget for showing the progress of making a Julia or Python kernel. -""" +"""Widget for showing the progress of making a Julia or Python kernel.""" import subprocess from PySide6.QtWidgets import QDialog, QMessageBox, QDialogButtonBox, QWidget from PySide6.QtCore import Slot, Qt, QTimer from PySide6.QtGui import QGuiApplication, QIcon from jupyter_client.kernelspec import find_kernel_specs -from spine_engine.utils.helpers import resolve_python_interpreter, resolve_julia_executable +from spine_engine.utils.helpers import resolve_current_python_interpreter, resolve_default_julia_executable from spinetoolbox.execution_managers import QProcessExecutionManager from spinetoolbox.helpers import ( busy_effect, @@ -187,8 +186,8 @@ def is_package_installed(python_path, package_name): Returns: (bool): True if installed, False if not """ - response = subprocess.check_output([python_path, '-m', 'pip', 'freeze', '-q']) - installed_packages = [r.decode().split('==')[0] for r in response.split()] + response = subprocess.check_output([python_path, "-m", "pip", "freeze", "-q"]) + installed_packages = [r.decode().split("==")[0] for r in response.split()] return package_name in installed_packages @busy_effect @@ -565,16 +564,13 @@ class MiniPythonKernelEditor(KernelEditorBase): the constructor, then calling ``make_kernel`` starts the process. """ - """A reduced version of KernelEditor that basically just takes care of installing one Python kernel. - The python exe is passed in the constructor, then calling ``make_kernel`` starts the process. - """ - def __init__(self, parent, python_exe): super().__init__(parent, "python") self.ui.label_message.setText("Finalizing Python configuration... ") self.ui.stackedWidget.setCurrentIndex(0) self.setWindowTitle("Python Kernel Specification Creator") - python_exe = resolve_python_interpreter(python_exe) + if not python_exe: + python_exe = resolve_current_python_interpreter() self.ui.lineEdit_python_interpreter.setText(python_exe) self.python_exe = python_exe self._kernel_name = "python_kernel" # Fallback name @@ -626,7 +622,8 @@ def __init__(self, parent, julia_exe, julia_project): self.ui.label_message.setText("Finalizing Julia configuration... ") self.ui.stackedWidget.setCurrentIndex(1) self.setWindowTitle("Julia Kernel Specification Creator") - julia_exe = resolve_julia_executable(julia_exe) + if not julia_exe: + julia_exe = resolve_default_julia_executable() self.ui.lineEdit_julia_executable.setText(julia_exe) self.ui.lineEdit_julia_project.setText(julia_project) self._kernel_name = "julia" # This is a prefix, IJulia decides the final kernel name diff --git a/spinetoolbox/widgets/link_properties_widget.py b/spinetoolbox/widgets/link_properties_widget.py index 70b5faa28..bac2a3fa0 100644 --- a/spinetoolbox/widgets/link_properties_widget.py +++ b/spinetoolbox/widgets/link_properties_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,17 +10,18 @@ # this program. If not, see . ###################################################################################################################### -""" -Link properties widget. -""" - +"""Link properties widget.""" from PySide6.QtCore import Slot from PySide6.QtWidgets import QMenu from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE -from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE from .properties_widget import PropertiesWidgetBase from .custom_qwidgets import PurgeSettingsDialog -from ..project_commands import SetConnectionOptionsCommand, SetConnectionDefaultFilterOnlineStatus +from ..mvcmodels.resource_filter_model import ResourceFilterModel +from ..project_commands import ( + SetConnectionFilterTypeEnabled, + SetConnectionOptionsCommand, + SetConnectionDefaultFilterOnlineStatus, +) class LinkPropertiesWidget(PropertiesWidgetBase): @@ -37,6 +39,9 @@ def __init__(self, toolbox, base_color=None): self._purge_settings_dialog = None self.ui = Ui_Form() self.ui.setupUi(self) + self.ui.filter_type_combo_box.addItems(sorted(ResourceFilterModel.FILTER_TYPES)) + self.ui.filter_type_combo_box.setCurrentText(ResourceFilterModel.FILTER_TYPE_TO_TEXT[SCENARIO_FILTER_TYPE]) + self.ui.filter_type_combo_box.currentTextChanged.connect(self._select_mutually_exclusive_filter) self._filter_validation_menu = QMenu(self) self._filter_validation_actions = self._populate_filter_validation_menu() self.ui.auto_check_filters_check_box.clicked.connect(self._handle_auto_check_filters_state_changed) @@ -63,7 +68,16 @@ def set_link(self, connection): self._toolbox.label_item_name.setText(f"Link {self._connection.link.name}") self.load_connection_options() may_have_filters = self._connection.may_have_filters() + self.ui.filter_type_combo_box.setEnabled(may_have_filters) self.ui.treeView_filters.setEnabled(may_have_filters) + if may_have_filters: + for filter_type, filter_text in ResourceFilterModel.FILTER_TYPE_TO_TEXT.items(): + filter_type_enabled = self._connection.is_filter_type_enabled(filter_type) + self._set_filter_type_expanded(filter_type, filter_type_enabled) + if filter_type_enabled: + self.ui.filter_type_combo_box.blockSignals(True) + self.ui.filter_type_combo_box.setCurrentText(filter_text) + self.ui.filter_type_combo_box.blockSignals(False) self.ui.auto_check_filters_check_box.setChecked(self._connection.is_filter_online_by_default) self.ui.auto_check_filters_check_box.setEnabled(may_have_filters) self.ui.open_filter_validation_menu_button.setEnabled(may_have_filters) @@ -104,10 +118,7 @@ def _populate_filter_validation_menu(self): Returns: dict: menu actions """ - action_data = { - "Require at least one checked scenario": SCENARIO_FILTER_TYPE, - "Require at least one checked tool": TOOL_FILTER_TYPE, - } + action_data = {"Require at least one checked scenario": SCENARIO_FILTER_TYPE} actions = {} for label, filter_type in action_data.items(): action = self._filter_validation_menu.addAction(label) @@ -194,3 +205,45 @@ def load_connection_options(self): self.ui.checkBox_purge_before_writing.setChecked(self._connection.purge_before_writing) self.ui.purge_settings_button.setEnabled(self._connection.purge_before_writing) self.ui.spinBox_write_index.setValue(self._connection.write_index) + + @Slot(str) + def _select_mutually_exclusive_filter(self, label): + enabled_filter_type = ResourceFilterModel.FILTER_TYPES[label] + disabled_filter_types = set(ResourceFilterModel.FILTER_TYPES.values()) - {enabled_filter_type} + self._toolbox.undo_stack.beginMacro(f"enable {label}s on connection {self._connection.link.name}") + for disabled_type in disabled_filter_types: + self._toolbox.undo_stack.push( + SetConnectionFilterTypeEnabled(self._toolbox.project(), self._connection, disabled_type, False) + ) + self._toolbox.undo_stack.push( + SetConnectionFilterTypeEnabled(self._toolbox.project(), self._connection, enabled_filter_type, True) + ) + self._toolbox.undo_stack.endMacro() + + def set_filter_type_enabled(self, filter_type, enabled): + """Enables or disables filter type in the tree. + + Args: + filter_type (str): filter type + enabled (bool): whether filter type is enabled + """ + self._set_filter_type_expanded(filter_type, enabled) + if not enabled: + return + filter_type_text = ResourceFilterModel.FILTER_TYPE_TO_TEXT.get(filter_type) + if filter_type_text is None: + return + self.ui.filter_type_combo_box.blockSignals(True) + self.ui.filter_type_combo_box.setCurrentText(filter_type_text) + self.ui.filter_type_combo_box.blockSignals(False) + + def _set_filter_type_expanded(self, filter_type, expanded): + """Expands or collapses filter type branch in the tree. + + Args: + filter_type (str): filter type + expanded (bool): True to expand the branch, False to collapse + """ + action = self.ui.treeView_filters.expand if expanded else self.ui.treeView_filters.collapse + for item in self._connection.resource_filter_model.filter_type_items(filter_type): + action(item.index()) diff --git a/spinetoolbox/widgets/map_editor.py b/spinetoolbox/widgets/map_editor.py index 60a623aa9..9dee83630 100644 --- a/spinetoolbox/widgets/map_editor.py +++ b/spinetoolbox/widgets/map_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,13 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor widget for editing a map type parameter values. -""" - +"""An editor widget for editing a map type parameter values.""" from PySide6.QtCore import QModelIndex, QPoint, Qt, Slot from PySide6.QtWidgets import QWidget - from spinedb_api import Map from ..helpers import inquire_index_name from .map_value_editor import MapValueEditor @@ -25,14 +22,13 @@ class MapEditor(QWidget): - """ - A widget for editing maps. - - Attributes: - parent (QWidget): - """ + """A widget for editing maps.""" def __init__(self, parent=None): + """ + Args: + parent (QWidget, optional): parent widget + """ from ..ui.map_editor import Ui_MapEditor # pylint: disable=import-outside-toplevel super().__init__(parent) @@ -67,6 +63,7 @@ def _show_table_context_menu(self, position): def set_value(self, value): """Sets the parameter_value to be edited.""" self._model.reset(value) + self._ui.map_table_view.resizeColumnsToContents() def value(self): """Returns the parameter_value currently being edited.""" diff --git a/spinetoolbox/widgets/map_value_editor.py b/spinetoolbox/widgets/map_value_editor.py index 888dd4c3d..4d74bee2f 100644 --- a/spinetoolbox/widgets/map_value_editor.py +++ b/spinetoolbox/widgets/map_value_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor dialog for map indexes and values. -""" +"""An editor dialog for map indexes and values.""" from PySide6.QtCore import Qt from .array_editor import ArrayEditor from .duration_editor import DurationEditor diff --git a/spinetoolbox/widgets/multi_tab_spec_editor.py b/spinetoolbox/widgets/multi_tab_spec_editor.py index c6bbedf42..e72ccbd9d 100644 --- a/spinetoolbox/widgets/multi_tab_spec_editor.py +++ b/spinetoolbox/widgets/multi_tab_spec_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains the MultiTabSpecEditor class. -""" - +"""Contains the MultiTabSpecEditor class.""" from PySide6.QtCore import Qt from PySide6.QtGui import QIcon from PySide6.QtWidgets import QMenu diff --git a/spinetoolbox/widgets/multi_tab_window.py b/spinetoolbox/widgets/multi_tab_window.py index 826adc5ba..d7da88f50 100644 --- a/spinetoolbox/widgets/multi_tab_window.py +++ b/spinetoolbox/widgets/multi_tab_window.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains the MultiTabWindow and TabBarPlus classes. -""" - +"""Contains the MultiTabWindow and TabBarPlus classes.""" from PySide6.QtWidgets import QMainWindow, QTabWidget, QWidget, QTabBar, QToolButton, QApplication, QMenu from PySide6.QtCore import Qt, Slot, QPoint, Signal, QEvent from PySide6.QtGui import QGuiApplication, QCursor, QIcon, QMouseEvent @@ -64,7 +62,7 @@ def others(self): """List of other MultiTabWindows of the same type. Returns: - list of MultiTabWindow: other MutliTabWindows windows + list of MultiTabWindow: other MultiTabWindows windows """ return [w for w in self._other_editor_windows[type(self).__name__] if w is not self] @@ -78,7 +76,7 @@ def _make_new_tab(self, *args, **kwargs): raise NotImplementedError() def show_plus_button_context_menu(self, global_pos): - """Opens a context menu for the tool bar. + """Opens a context menu for the toolbar. Args: global_pos (QPoint): menu position on screen @@ -125,11 +123,17 @@ def add_new_tab(self, *args, **kwargs): Args: *args: parameters forwarded to :func:`MutliTabWindow._make_new_tab` **kwargs: parameters forwarded to :func:`MultiTabwindow._make_new_tab` + + Returns: + bool: True if successful, False otherwise """ if not self._accepting_new_tabs: - return + return False tab = self._make_new_tab(*args, **kwargs) + if not tab: + return False self._add_connect_tab(tab, self.new_tab_title) + return True def insert_new_tab(self, index, *args, **kwargs): """Creates a new tab and inserts it at the given index. @@ -410,7 +414,7 @@ def restore_ui(self): window_size = self.qsettings.value("windowSize") window_pos = self.qsettings.value("windowPosition") window_state = self.qsettings.value("windowState") - window_maximized = self.qsettings.value("windowMaximized", defaultValue='false') + window_maximized = self.qsettings.value("windowMaximized", defaultValue="false") n_screens = self.qsettings.value("n_screens", defaultValue=1) self.qsettings.endGroup() original_size = self.size() @@ -424,7 +428,7 @@ def restore_ui(self): # There are less screens available now than on previous application startup self.move(0, 0) # Move this widget to primary screen position (0,0) ensure_window_is_on_screen(self, original_size) - if window_maximized == 'true': + if window_maximized == "true": self.setWindowState(Qt.WindowMaximized) def save_window_state(self): diff --git a/spinetoolbox/widgets/notification.py b/spinetoolbox/widgets/notification.py index 4d48b2a7f..3e0ac08a4 100644 --- a/spinetoolbox/widgets/notification.py +++ b/spinetoolbox/widgets/notification.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a notification widget. -""" - +"""Contains a notification widget.""" from PySide6.QtWidgets import QFrame, QLabel, QHBoxLayout, QGraphicsOpacityEffect, QLayout, QSizePolicy, QPushButton from PySide6.QtCore import Qt, Slot, QTimer, QPropertyAnimation, Property, QObject from PySide6.QtGui import QFont, QColor diff --git a/spinetoolbox/widgets/open_project_widget.py b/spinetoolbox/widgets/open_project_dialog.py similarity index 96% rename from spinetoolbox/widgets/open_project_widget.py rename to spinetoolbox/widgets/open_project_dialog.py index d57196c91..25741050c 100644 --- a/spinetoolbox/widgets/open_project_widget.py +++ b/spinetoolbox/widgets/open_project_dialog.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains a class for a widget that represents a 'Open Project Directory' dialog. -""" - +"""Contains a class for a widget that represents a 'Open Project Directory' dialog.""" import os from PySide6.QtWidgets import QDialog, QFileSystemModel, QAbstractItemView, QComboBox from PySide6.QtCore import Qt, Slot, QDir, QStandardPaths, QModelIndex @@ -35,7 +33,7 @@ def __init__(self, toolbox): from ..ui import open_project_dialog # pylint: disable=import-outside-toplevel super().__init__(parent=toolbox, f=Qt.Dialog) # Setting the parent inherits the stylesheet - self._toolbox = toolbox + self._qsettings = toolbox.qsettings() # Set up the user interface from Designer file self.ui = open_project_dialog.Ui_Dialog() self.ui.setupUi(self) @@ -67,7 +65,7 @@ def __init__(self, toolbox): self.ui.comboBox_current_path.setValidator(self.validator) self.ui.comboBox_current_path.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) # Read recent project directories and populate combobox - recents = self._toolbox.qsettings().value("appSettings/recentProjectStorages", defaultValue=None) + recents = self._qsettings.value("appSettings/recentProjectStorages", defaultValue=None) if recents: recents_lst = str(recents).split("\n") self.ui.comboBox_current_path.insertItems(0, recents_lst) @@ -89,13 +87,13 @@ def __init__(self, toolbox): def set_keyboard_shortcuts(self): """Creates keyboard shortcuts for the 'Root', 'Home', etc. buttons.""" - self.go_root_action.setShortcut(QKeySequence(Qt.Key_F1)) + self.go_root_action.setShortcut(QKeySequence(Qt.Key.Key_F1)) self.addAction(self.go_root_action) - self.go_home_action.setShortcut(QKeySequence(Qt.Key_F2)) + self.go_home_action.setShortcut(QKeySequence(Qt.Key.Key_F2)) self.addAction(self.go_home_action) - self.go_documents_action.setShortcut(QKeySequence(Qt.Key_F3)) + self.go_documents_action.setShortcut(QKeySequence(Qt.Key.Key_F3)) self.addAction(self.go_documents_action) - self.go_desktop_action.setShortcut(QKeySequence(Qt.Key_F4)) + self.go_desktop_action.setShortcut(QKeySequence(Qt.Key.Key_F4)) self.addAction(self.go_desktop_action) def connect_signals(self): @@ -160,7 +158,7 @@ def current_index_changed(self, i): """ p = self.ui.comboBox_current_path.itemText(i) if not os.path.isdir(p): - self.remove_directory_from_recents(p, self._toolbox.qsettings()) + self.remove_directory_from_recents(p, self._qsettings) return fm_index = self.file_model.index(p) self.ui.treeView_file_system.collapseAll() @@ -290,9 +288,7 @@ def done(self, r): return # self.selection() now contains a valid Spine Toolbox project directory. # Add the parent directory of selected directory to qsettings - self.update_recents( - os.path.abspath(os.path.join(self.selection(), os.path.pardir)), self._toolbox.qsettings() - ) + self.update_recents(os.path.abspath(os.path.join(self.selection(), os.path.pardir)), self._qsettings) super().done(r) @staticmethod @@ -357,7 +353,7 @@ def show_context_menu(self, pos): action = self.combobox_context_menu.get_action() if action == "Clear history": self.ui.comboBox_current_path.clear() - self._toolbox.qsettings().setValue("appSettings/recentProjectStorages", "") + self._qsettings.setValue("appSettings/recentProjectStorages", "") self.go_root() else: # No option selected pass diff --git a/spinetoolbox/widgets/options_dialog.py b/spinetoolbox/widgets/options_dialog.py new file mode 100644 index 000000000..eab99e4de --- /dev/null +++ b/spinetoolbox/widgets/options_dialog.py @@ -0,0 +1,95 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Provides OptionsDialog.""" +from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QButtonGroup, QRadioButton, QFrame, QStyle, QDialogButtonBox +from PySide6.QtCore import Slot, Qt + + +class OptionsDialog(QDialog): + """A dialog with options.""" + + def __init__(self, parent, title, text, option_to_answer, notes=None, preferred=None): + """ + Args: + parent (QWidget): the parent widget + title (srt): title of the window + text (str): text to show to the user + option_to_answer (dict): mapping option string to corresponding answer to return + preferred (int,optional): preselected option if any + """ + super().__init__(parent) + if notes is None: + notes = {} + self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) + self.setWindowModality(Qt.ApplicationModal) + self.setWindowTitle(title) + layout = QVBoxLayout(self) + if option_to_answer: + text += "

Please select an option:" + text_label = QLabel(text) + text_label.setWordWrap(True) + layout.addWidget(text_label) + options_frame = QFrame() + options_layout = QVBoxLayout(options_frame) + self._button_group = QButtonGroup() + for i, o in enumerate(option_to_answer): + note = notes.get(o) + if i == preferred: + o += " (RECOMMENDED)" + option_button = QRadioButton(o) + options_layout.addWidget(option_button) + self._button_group.addButton(option_button, id=i) + if note is not None: + note_label = QLabel(note) + note_label.setWordWrap(True) + indent = sum( + self.style().pixelMetric(pm) + for pm in ( + QStyle.PixelMetric.PM_ExclusiveIndicatorWidth, + QStyle.PixelMetric.PM_RadioButtonLabelSpacing, + ) + ) + note_label.setIndent(indent) + font = note_label.font() + font.setPointSize(font.pointSize() - 1) + note_label.setFont(font) + note_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + options_layout.addWidget(note_label) + layout.addWidget(options_frame) + button_box = QDialogButtonBox(self) + self._ok_button = button_box.addButton("Ok", QDialogButtonBox.AcceptRole) + button_box.accepted.connect(self.accept) + if option_to_answer: + button_box.addButton("Cancel", QDialogButtonBox.RejectRole) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + if preferred is not None: + self._button_group.button(preferred).setChecked(True) + self._button_group.idToggled.connect(self._update_ok_button_enabled) + self._update_ok_button_enabled() + + @classmethod + def get_answer(cls, parent, title, text, option_to_answer, notes=None, preferred=None): + obj = cls(parent, title, text, option_to_answer, notes=notes, preferred=preferred) + obj.exec() + if obj.result() != QDialog.Accepted: + return None + id_ = obj._button_group.checkedId() + if id_ == -1: + return None + option = list(option_to_answer)[id_] + return option_to_answer[option] + + @Slot(int) + def _update_ok_button_enabled(self, _id=None): + self._ok_button.setEnabled(not self._button_group.buttons() or self._button_group.checkedButton() is not None) diff --git a/spinetoolbox/widgets/parameter_value_editor.py b/spinetoolbox/widgets/parameter_value_editor.py index 76d3ca0e9..31037a264 100644 --- a/spinetoolbox/widgets/parameter_value_editor.py +++ b/spinetoolbox/widgets/parameter_value_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,11 +10,10 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor dialog for editing database (relationship) parameter values. -""" +"""An editor dialog for editing database (relationship) parameter values.""" from PySide6.QtWidgets import QMessageBox from spinedb_api import ParameterValueFormatError, to_database +from spinedb_api.parameter_value import deep_copy_value from .array_editor import ArrayEditor from .duration_editor import DurationEditor from .datetime_editor import DatetimeEditor @@ -54,7 +54,8 @@ def __init__(self, index, parent=None, plain=False): self._index = index self.set_data_delayed = model.get_set_data_delayed(index) self.setWindowTitle(f"Edit value -- {model.index_name(index)} --") - self._select_editor(index.data(PARSED_ROLE)) + value = deep_copy_value(index.data(PARSED_ROLE)) + self._select_editor(value) def _set_data(self, value): """See base class.""" diff --git a/spinetoolbox/widgets/parameter_value_editor_base.py b/spinetoolbox/widgets/parameter_value_editor_base.py index 1901a19f1..53adb3259 100644 --- a/spinetoolbox/widgets/parameter_value_editor_base.py +++ b/spinetoolbox/widgets/parameter_value_editor_base.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A base for editor windows for editing parameter values. -""" - +"""A base for editor windows for editing parameter values.""" from enum import auto, Enum, unique from numbers import Number from PySide6.QtCore import Qt, Slot diff --git a/spinetoolbox/widgets/persistent_console_widget.py b/spinetoolbox/widgets/persistent_console_widget.py index f917c0170..091f64ecf 100644 --- a/spinetoolbox/widgets/persistent_console_widget.py +++ b/spinetoolbox/widgets/persistent_console_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,7 @@ # this program. If not, see . ###################################################################################################################### +"""Contains a widget acting as a console for Julia & Python REPL's.""" import os import uuid from pygments.styles import get_style_by_name @@ -381,8 +383,8 @@ def _flush_text_buffer(self): if self._text_buffer: address = uuid.uuid4().hex char_format = cursor.charFormat() - char_format.setBackground(QColor('white')) - char_format.setForeground(QColor('blue')) + char_format.setBackground(QColor("white")) + char_format.setForeground(QColor("blue")) char_format.setAnchor(True) char_format.setAnchorHref(address) self._skipped[address] = self._text_buffer[-self._MAX_LINES_COUNT :] diff --git a/spinetoolbox/widgets/plain_parameter_value_editor.py b/spinetoolbox/widgets/plain_parameter_value_editor.py index 0e409083a..2bb2353da 100644 --- a/spinetoolbox/widgets/plain_parameter_value_editor.py +++ b/spinetoolbox/widgets/plain_parameter_value_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor widget for editing plain number database (relationship) parameter values. -""" - +"""An editor widget for editing plain number database (relationship) parameter values.""" from PySide6.QtCore import Slot from PySide6.QtWidgets import QWidget from spinetoolbox.helpers import try_number_from_string diff --git a/spinetoolbox/widgets/plot_canvas.py b/spinetoolbox/widgets/plot_canvas.py index 86f3b3c3a..011ae1945 100644 --- a/spinetoolbox/widgets/plot_canvas.py +++ b/spinetoolbox/widgets/plot_canvas.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A Qt widget to use as a matplotlib backend. -""" +"""A Qt widget to use as a matplotlib backend.""" from enum import auto, Enum, unique from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure diff --git a/spinetoolbox/widgets/plot_widget.py b/spinetoolbox/widgets/plot_widget.py index a14555aac..4deace960 100644 --- a/spinetoolbox/widgets/plot_widget.py +++ b/spinetoolbox/widgets/plot_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,10 @@ # this program. If not, see . ###################################################################################################################### -""" -A Qt widget showing a toolbar and a matplotlib plotting canvas. -""" - +"""A Qt widget showing a toolbar and a matplotlib plotting canvas.""" import itertools import io import csv - import numpy from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolBar from PySide6.QtCore import QMetaObject, Qt diff --git a/spinetoolbox/widgets/plugin_manager_widgets.py b/spinetoolbox/widgets/plugin_manager_widgets.py index 3fafd1f9f..cfa4f8dbf 100644 --- a/spinetoolbox/widgets/plugin_manager_widgets.py +++ b/spinetoolbox/widgets/plugin_manager_widgets.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains PluginManager dialogs and widgets. -""" +"""Contains PluginManager dialogs and widgets.""" from PySide6.QtCore import Qt, Slot, Signal, QSortFilterProxyModel, QTimer, QSize from PySide6.QtWidgets import QDialog, QVBoxLayout, QLineEdit, QListView, QDialogButtonBox from PySide6.QtGui import QStandardItemModel, QStandardItem @@ -37,7 +36,7 @@ def __init__(self, parent): """Initialize class""" super().__init__(parent) self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) - self.setWindowTitle('Install plugin') + self.setWindowTitle("Install plugin") QVBoxLayout(self) self._line_edit = QLineEdit(self) self._line_edit.setPlaceholderText("Search registry...") @@ -101,7 +100,7 @@ def __init__(self, parent): """Initialize class""" super().__init__(parent) self.setWindowFlag(Qt.WindowType.WindowContextHelpButtonHint, False) - self.setWindowTitle('Manage plugins') + self.setWindowTitle("Manage plugins") QVBoxLayout(self) self._list_view = QListView(self) self._model = _ManagePluginsModel(self) diff --git a/spinetoolbox/widgets/project_item_drag.py b/spinetoolbox/widgets/project_item_drag.py index e71a21084..247a6f904 100644 --- a/spinetoolbox/widgets/project_item_drag.py +++ b/spinetoolbox/widgets/project_item_drag.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes for custom QListView. -""" - +"""Classes for custom QListView.""" from textwrap import fill -from PySide6.QtCore import QModelIndex, Qt, Signal, Slot, QMimeData, QMargins -from PySide6.QtGui import QDrag, QIcon, QPainter, QBrush, QColor, QFont, QIconEngine -from PySide6.QtWidgets import QToolButton, QApplication, QToolBar, QWidgetAction, QStyle -from ..helpers import CharIconEngine, make_icon_background +from PySide6.QtCore import Qt, Signal, Slot, QMimeData +from PySide6.QtGui import QDrag, QIcon, QPainter, QBrush, QColor, QIconEngine, QCursor +from PySide6.QtWidgets import QToolButton, QApplication, QToolTip class ProjectItemDragMixin: @@ -27,9 +24,17 @@ class ProjectItemDragMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._reset() + + def _reset(self): self.drag_start_pos = None self.pixmap = None self.mime_data = None + self.setCursor(Qt.OpenHandCursor) + + def mousePressEvent(self, event): + super().mousePressEvent(event) + self.setCursor(Qt.ClosedHandCursor) def mouseMoveEvent(self, event): """Start dragging action if needed""" @@ -53,9 +58,11 @@ def mouseMoveEvent(self, event): def mouseReleaseEvent(self, event): """Forget drag start position""" super().mouseReleaseEvent(event) - self.drag_start_pos = None - self.pixmap = None - self.mime_data = None + self._reset() + + def enterEvent(self, event): + super().enterEvent(event) + self.setCursor(Qt.OpenHandCursor) class NiceButton(QToolButton): @@ -64,7 +71,6 @@ def __init__(self, *args, **kwargs): font = self.font() font.setPointSize(9) self.setFont(font) - self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) def setText(self, text): super().setText(fill(text, width=12, break_long_words=False)) @@ -72,8 +78,10 @@ def setText(self, text): def set_orientation(self, orientation): if orientation == Qt.Orientation.Horizontal: self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + self.setStyleSheet("QToolButton{margin: 16px 2px 2px 2px;}") else: self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.setStyleSheet("QToolButton{margin: 2px;}") class ProjectItemButtonBase(ProjectItemDragMixin, NiceButton): @@ -85,7 +93,11 @@ def __init__(self, toolbox, item_type, icon, parent=None): self.setIcon(icon) self.setMouseTracking(True) self.drag_about_to_start.connect(self._handle_drag_about_to_start) - self.setStyleSheet("QToolButton{padding: 2px}") + self.clicked.connect(self._show_tool_tip) + + @Slot(bool) + def _show_tool_tip(self, _=False): + QToolTip.showText(QCursor.pos(), self.toolTip()) def set_colored_icons(self, colored): self._icon.set_colored(colored) @@ -194,298 +206,3 @@ def update(self): def pixmap(self, size, mode, state): return self._pixmap - - -class ProjectItemSpecArray(QToolBar): - """An array of ProjectItemSpecButton that can be expanded/collapsed.""" - - def __init__(self, toolbox, model, item_type, icon): - """ - Args: - toolbox (ToolboxUI) - model (FilteredSpecificationModel) - item_type (str) - icon (ColoredIcon) - """ - super().__init__() - self._extension_button = next(iter(self.findChildren(QToolButton))) - self._margins = QMargins(4, 4, 4, 4) - self.layout().setContentsMargins(self._margins) - self._maximum_size = self.maximumSize() - self._model = model - self._toolbox = toolbox - self.item_type = item_type - self._icon = icon - self._visible = False - self._button_base_item = ProjectItemButton(self._toolbox, self.item_type, self._icon) - self._button_base_item.double_clicked.connect(self.toggle_visibility) - self.addWidget(self._button_base_item) - self._button_visible = QToolButton() - font = QFont("Font Awesome 5 Free Solid") - font.setPointSize(8) - self._button_visible.setFont(font) - self._button_visible.setToolTip(f"

Show/hide {self.item_type} specifications

") - self.addWidget(self._button_visible) - self._button_new = ShadeButton() - self._button_new.setIcon(QIcon(CharIconEngine("\uf067", color=self._icon.color()))) - self._button_new.setIconSize(self.iconSize()) - self._button_new.setText("New...") - self._button_new.setToolTip(f"

Create new {item_type} specification...

") - self._action_new = self.addWidget(self._button_new) - self._action_new.setVisible(self._visible) - self._actions = {} - self._chopped_icon = _ChoppedIcon(self._icon, self.iconSize()) - self._button_filling = ShadeProjectItemSpecButton(self._toolbox, self.item_type, self._chopped_icon) - self._button_filling.setParent(self) - self._button_filling.setVisible(False) - self._model.rowsInserted.connect(self._insert_specs) - self._model.rowsAboutToBeRemoved.connect(self._remove_specs) - self._model.modelReset.connect(self._reset_specs) - self._button_visible.clicked.connect(self.toggle_visibility) - self._button_new.clicked.connect(self._show_spec_form) - self.orientationChanged.connect(self._update_button_geom) - - def set_colored_icons(self, colored): - self._icon.set_colored(colored) - self.update() - - def update(self): - self._chopped_icon.update() - self._update_button_visible_icon_color() - - def _update_button_visible_icon_color(self): - mode = QIcon.Active if self._button_base_item.isEnabled() else QIcon.Disabled - color = self._icon.color(mode=mode) - self._button_visible.setStyleSheet(f"QToolButton{{ color: {color.name()};}}") - - def set_color(self, color): - bg = make_icon_background(color) - ss = f"QMenu {{background: {bg};}}" - self._extension_button.menu().setStyleSheet(ss) - - def paintEvent(self, ev): - super().paintEvent(ev) - if not self._visible: - return - actions, ind = self._get_first_chopped_index() - self._add_filling(actions, ind) - self._populate_extension_menu(actions, ind) - - def _get_first_chopped_index(self): - """Returns the index of the first chopped action (chopped = not drawn because of space). - - Returns: - list(QAction) - int or NoneType - """ - actions_iter = (self._actions.get(spec.name) for spec in self._model.specifications()) - actions = [act for act in actions_iter if act is not None] - if self.orientation() == Qt.Orientation.Horizontal: - get_point = lambda ref_geom: (ref_geom.right() + 1, ref_geom.top()) - else: - get_point = lambda ref_geom: (ref_geom.left(), ref_geom.bottom() + 1) - ref_widget = self._button_new - for i, act in enumerate(actions): - ref_geom = ref_widget.geometry() - x, y = get_point(ref_geom) - if not self.actionAt(x, y): - return actions, i - ref_widget = self.widgetForAction(act) - return actions, None - - def _add_filling(self, actions, ind): - """Adds a button to fill empty space after the last visible action. - - Args: - actions (list(QAction)): actions - ind (int or NoneType): index of the first chopped one or None if all are visible - """ - if ind is None: - self._button_filling.setVisible(False) - return - if ind > 0: - previous = self.widgetForAction(actions[ind - 1]) - else: - previous = self._button_new - x, y, w, h = self._get_filling(previous) - if w <= 0 or h <= 0: - self._button_filling.setVisible(False) - return - self._button_filling.move(x, y) - self._button_filling.setFixedSize(w, h) - self._button_filling.setVisible(True) - button = self.widgetForAction(actions[ind]) - self._button_filling.spec_name = button.spec_name - - def _get_filling(self, previous): - """Returns the position and size of the filling widget. - - Args: - previous (QWidget): last visible widget - - Returns: - int: position x - int: position y - int: width - int: height - """ - geom = previous.geometry() - style = self.style() - extension_extent = style.pixelMetric(QStyle.PixelMetric.PM_ToolBarExtensionExtent) - if self.orientation() == Qt.Orientation.Horizontal: - toolbar_size = self.width() - extension_extent - 2 * self._margins.left() + 2 - x, y = geom.right() + 1, geom.top() - w, h = toolbar_size - geom.right(), geom.height() - else: - toolbar_size = self.height() - extension_extent - 2 * self._margins.top() + 2 - x, y = geom.left(), geom.bottom() + 1 - w, h = geom.width(), toolbar_size - geom.bottom() - return x, y, w, h - - def _populate_extension_menu(self, actions, ind): - """Populates extension menu with chopped actions. - - Args: - actions (list(QAction)): actions - ind (int or NoneType): index of the first chopped one or None if all are visible - """ - self._extension_button.setEnabled(True) - menu = self._extension_button.menu() - menu.clear() - if ind is None: - return - ss = ( - "QToolButton {background-color: rgba(255,255,255,0); border: 1px solid transparent; padding: 3px}" - "QToolButton:hover {background-color: white; border: 1px solid lightGray; padding: 3px}" - ) - chopped_actions = iter(actions[ind:]) - for act in chopped_actions: - button = self.widgetForAction(act).clone() - button.setIconSize(self.iconSize()) - button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) - button.setStyleSheet(ss) - action = QWidgetAction(menu) - action.setDefaultWidget(button) - menu.addAction(action) - - def showEvent(self, ev): - super().showEvent(ev) - self._update_button_geom() - - def _update_button_geom(self, orientation=None): - """Updates geometry of buttons given the orientation - - Args: - orientation (Qt.Orientation) - """ - spacing = 2 # additional space till next toolbar icon when collapsed - if orientation is None: - orientation = self.orientation() - self._button_base_item.set_orientation(orientation) - self._button_new.set_orientation(orientation) - widgets = [self.widgetForAction(a) for a in self._actions.values()] - for w in widgets: - w.set_orientation(orientation) - style = self.style() - extent = style.pixelMetric(QStyle.PixelMetric.PM_ToolBarExtensionExtent) - down, right = "\uf0d7", "\uf0da" - if orientation == Qt.Orientation.Horizontal: - icon = down if not self._visible else right - width = extent - min_width = self._button_base_item.sizeHint().width() + extent + self._margins.left() + spacing - min_visible_width = min_width + self._button_new.sizeHint().width() - spacing - if widgets: - min_visible_width += extent - min_height = self._button_base_item.sizeHint().height() - min_size = (min_width, min_height) - min_visible_size = (min_visible_width, min_height) - height = max((w.sizeHint().height() for w in widgets), default=min_height) - self._button_new.setMaximumHeight(height) - for w in widgets: - w.setMaximumWidth(w.sizeHint().width()) - w.setMaximumHeight(height) - else: - icon = right if not self._visible else down - height = extent - min_width = self._button_base_item.sizeHint().width() - min_height = self._button_base_item.sizeHint().height() + extent + self._margins.top() + spacing - min_visible_height = min_height + self._button_new.sizeHint().height() - spacing - if widgets: - min_visible_height += extent - min_size = (min_width, min_height) - min_visible_size = (min_width, min_visible_height) - width = max((w.sizeHint().width() for w in widgets), default=min_width) - self._button_new.setMaximumWidth(width) - for w in widgets: - w.setMaximumWidth(width) - w.setMaximumHeight(w.sizeHint().height()) - self._button_visible.setText(icon) - self._button_visible.setMaximumSize(width, height) - if not self._visible: - self.setFixedSize(*min_size) - self.setStyleSheet("QToolBar {background: transparent}") - else: - self.setMaximumSize(self._maximum_size) - self.setMinimumSize(*min_visible_size) - self.setStyleSheet("") - - @Slot(bool) - def _show_spec_form(self, _checked=False): - self._toolbox.show_specification_form(self.item_type) - - @Slot(bool) - def toggle_visibility(self, _checked=False): - self.set_visible(not self._visible) - self._update_button_geom() - - def set_visible(self, visible): - self._visible = visible - for action in self._actions.values(): - action.setVisible(self._visible) - self._action_new.setVisible(self._visible) - - @Slot(QModelIndex, int, int) - def _insert_specs(self, parent, first, last): - for row in range(first, last + 1): - self._add_spec(row) - self._update_button_geom() - - @Slot(QModelIndex, int, int) - def _remove_specs(self, parent, first, last): - for row in range(first, last + 1): - self._remove_spec(row) - self._update_button_geom() - - def _remove_spec(self, row): - spec_name = self._model.index(row, 0).data(Qt.ItemDataRole.DisplayRole) - try: - action = self._actions.pop(spec_name) - self.removeAction(action) - except KeyError: - pass # Happens when Plugins are removed - - @Slot() - def _reset_specs(self): - for action in self._actions.values(): - self.removeAction(action) - self._actions.clear() - for row in range(self._model.rowCount()): - self._add_spec(row) - self._update_button_geom() - - def _add_spec(self, row): - spec = self._model.specification(row) - if spec.plugin: - return - next_row = row + 1 - while True: - next_spec = self._model.specification(next_row) - if next_spec is None or not next_spec.plugin: - break - next_row += 1 - button = ShadeProjectItemSpecButton(self._toolbox, spec.item_type, self._icon, spec.name) - button.setIconSize(self.iconSize()) - button.set_orientation(self.orientation()) - action = self.insertWidget(self._actions[next_spec.name], button) if next_spec else self.addWidget(button) - action.setVisible(self._visible) - self._actions[spec.name] = action diff --git a/spinetoolbox/widgets/properties_widget.py b/spinetoolbox/widgets/properties_widget.py index cf723a3d0..0fecd336f 100644 --- a/spinetoolbox/widgets/properties_widget.py +++ b/spinetoolbox/widgets/properties_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains PropertiesWidgetBase. -""" - +"""Contains PropertiesWidgetBase.""" from PySide6.QtWidgets import QWidget, QAbstractItemView, QLineEdit, QHeaderView from PySide6.QtCore import Qt, QRect, QPoint, QEvent from PySide6.QtGui import QPainter, QPixmap, QColor diff --git a/spinetoolbox/widgets/report_plotting_failure.py b/spinetoolbox/widgets/report_plotting_failure.py index ec4845917..9d197c597 100644 --- a/spinetoolbox/widgets/report_plotting_failure.py +++ b/spinetoolbox/widgets/report_plotting_failure.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Functions to report failures in plotting to the user. -""" - +"""Functions to report failures in plotting to the user.""" from PySide6.QtWidgets import QMessageBox diff --git a/spinetoolbox/widgets/select_database_items.py b/spinetoolbox/widgets/select_database_items.py index 66c7f4d78..50fa64d37 100644 --- a/spinetoolbox/widgets/select_database_items.py +++ b/spinetoolbox/widgets/select_database_items.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,13 +9,11 @@ # 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 . ###################################################################################################################### -""" -A widget and utilities to select database items. -""" + +"""A widget and utilities to select database items.""" from PySide6.QtCore import Signal, Slot from PySide6.QtWidgets import QCheckBox, QWidget - -from spinedb_api.db_mapping_base import DatabaseMappingBase +from spinedb_api.db_mapping import DatabaseMapping def add_check_boxes(check_boxes, checked_states, select_all_button, deselect_all_button, state_changed_slot, layout): @@ -56,8 +55,7 @@ class SelectDatabaseItems(QWidget): checked_state_changed = Signal(int) COLUMN_COUNT = 3 _DATA_ITEMS = ( - "object", - "relationship", + "entity", "entity_group", "parameter_value", "entity_metadata", @@ -78,10 +76,11 @@ def __init__(self, checked_states=None, parent=None): self._ui.setupUi(self) self._ui.select_data_items_button.clicked.connect(self._select_data_items) self._ui.select_scenario_items_button.clicked.connect(self._select_scenario_items) + checkable_item_types = tuple(type_ for type_ in DatabaseMapping.item_types() if type_ != "commit") checked_states = ( - checked_states if checked_states is not None else {item: False for item in DatabaseMappingBase.ITEM_TYPES} + checked_states if checked_states is not None else {item: False for item in checkable_item_types} ) - self._item_check_boxes = {item_type: QCheckBox(item_type, self) for item_type in DatabaseMappingBase.ITEM_TYPES} + self._item_check_boxes = {item_type: QCheckBox(item_type, self) for item_type in checkable_item_types} add_check_boxes( self._item_check_boxes, checked_states, diff --git a/spinetoolbox/widgets/set_description_dialog.py b/spinetoolbox/widgets/set_description_dialog.py index 3884da7df..8cca5db41 100644 --- a/spinetoolbox/widgets/set_description_dialog.py +++ b/spinetoolbox/widgets/set_description_dialog.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -A widget for editing project description -""" - +"""A widget for editing project description.""" from PySide6.QtCore import Qt, Slot from PySide6.QtWidgets import QDialog, QFormLayout, QLabel, QPlainTextEdit, QDialogButtonBox diff --git a/spinetoolbox/widgets/settings_widget.py b/spinetoolbox/widgets/settings_widget.py index 8fe69fa65..a89b89be3 100644 --- a/spinetoolbox/widgets/settings_widget.py +++ b/spinetoolbox/widgets/settings_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,17 +10,14 @@ # this program. If not, see . ###################################################################################################################### -""" -Widget for controlling user settings. -""" - +"""Widget for controlling user settings.""" import os -from PySide6.QtWidgets import QWidget, QFileDialog, QColorDialog, QApplication, QMenu, QMessageBox +from PySide6.QtWidgets import QWidget, QFileDialog, QColorDialog, QMenu, QMessageBox from PySide6.QtCore import Slot, Qt, QSize, QSettings, QPoint, QEvent from PySide6.QtGui import QPixmap, QIcon, QStandardItemModel, QStandardItem from spine_engine.utils.helpers import ( - resolve_python_interpreter, - resolve_julia_executable, + resolve_current_python_interpreter, + resolve_default_julia_executable, resolve_gams_executable, resolve_conda_executable, get_julia_env, @@ -46,6 +44,7 @@ dir_is_valid, home_dir, open_url, + is_valid_conda_executable, ) @@ -153,33 +152,49 @@ class SpineDBEditorSettingsMixin: def connect_signals(self): """Connect signals.""" super().connect_signals() - self.ui.checkBox_auto_expand_objects.clicked.connect(self.set_auto_expand_objects) + self.ui.checkBox_hide_empty_classes.clicked.connect(self.set_hide_empty_classes) + self.ui.checkBox_auto_expand_entities.clicked.connect(self.set_auto_expand_entities) self.ui.checkBox_merge_dbs.clicked.connect(self.set_merge_dbs) + self.ui.checkBox_snap_entities.clicked.connect(self.set_snap_entities) + self.ui.spinBox_max_ent_dim_count.valueChanged.connect(self.set_max_entity_dimension_count) + self.ui.spinBox_layout_algo_max_iterations.valueChanged.connect(self.set_build_iters) + self.ui.spinBox_layout_algo_spread_factor.valueChanged.connect(self.set_spread_factor) + self.ui.spinBox_layout_algo_neg_weight_exp.valueChanged.connect(self.set_neg_weight_exp) def read_settings(self): """Read saved settings from app QSettings instance and update UI to display them.""" commit_at_exit = int(self._qsettings.value("appSettings/commitAtExit", defaultValue="1")) # tri-state sticky_selection = self._qsettings.value("appSettings/stickySelection", defaultValue="false") + hide_empty_classes = self._qsettings.value("appSettings/hideEmptyClasses", defaultValue="false") smooth_zoom = self._qsettings.value("appSettings/smoothEntityGraphZoom", defaultValue="false") smooth_rotation = self._qsettings.value("appSettings/smoothEntityGraphRotation", defaultValue="false") - relationship_items_follow = self._qsettings.value("appSettings/relationshipItemsFollow", defaultValue="true") - auto_expand_objects = self._qsettings.value("appSettings/autoExpandObjects", defaultValue="true") + auto_expand_entities = self._qsettings.value("appSettings/autoExpandObjects", defaultValue="true") + snap_entities = self._qsettings.value("appSettings/snapEntities", defaultValue="false") merge_dbs = self._qsettings.value("appSettings/mergeDBs", defaultValue="true") db_editor_show_undo = int(self._qsettings.value("appSettings/dbEditorShowUndo", defaultValue="2")) + max_ent_dim_count = int(self.qsettings.value("appSettings/maxEntityDimensionCount", defaultValue="5")) + build_iters = int(self.qsettings.value("appSettings/layoutAlgoBuildIterations", defaultValue="12")) + spread_factor = int(self.qsettings.value("appSettings/layoutAlgoSpreadFactor", defaultValue="100")) + neg_weight_exp = int(self.qsettings.value("appSettings/layoutAlgoNegWeightExp", defaultValue="2")) if commit_at_exit == 0: # Not needed but makes the code more readable. self.ui.checkBox_commit_at_exit.setCheckState(Qt.CheckState.Unchecked) elif commit_at_exit == 1: self.ui.checkBox_commit_at_exit.setCheckState(Qt.PartiallyChecked) else: # commit_at_exit == "2": self.ui.checkBox_commit_at_exit.setCheckState(Qt.CheckState.Checked) - self.ui.checkBox_object_tree_sticky_selection.setChecked(sticky_selection == "true") + self.ui.checkBox_entity_tree_sticky_selection.setChecked(sticky_selection == "true") + self.ui.checkBox_hide_empty_classes.setChecked(hide_empty_classes == "true") self.ui.checkBox_smooth_entity_graph_zoom.setChecked(smooth_zoom == "true") self.ui.checkBox_smooth_entity_graph_rotation.setChecked(smooth_rotation == "true") - self.ui.checkBox_relationship_items_follow.setChecked(relationship_items_follow == "true") - self.ui.checkBox_auto_expand_objects.setChecked(auto_expand_objects == "true") + self.ui.checkBox_auto_expand_entities.setChecked(auto_expand_entities == "true") + self.ui.checkBox_snap_entities.setChecked(snap_entities == "true") self.ui.checkBox_merge_dbs.setChecked(merge_dbs == "true") if db_editor_show_undo == 2: self.ui.checkBox_db_editor_show_undo.setChecked(True) + self.ui.spinBox_max_ent_dim_count.setValue(max_ent_dim_count) + self.ui.spinBox_layout_algo_max_iterations.setValue(build_iters) + self.ui.spinBox_layout_algo_spread_factor.setValue(spread_factor) + self.ui.spinBox_layout_algo_neg_weight_exp.setValue(neg_weight_exp) def save_settings(self): """Get selections and save them to persistent memory.""" @@ -187,38 +202,77 @@ def save_settings(self): return False commit_at_exit = str(self.ui.checkBox_commit_at_exit.checkState().value) self._qsettings.setValue("appSettings/commitAtExit", commit_at_exit) - sticky_selection = "true" if self.ui.checkBox_object_tree_sticky_selection.checkState().value else "false" + sticky_selection = "true" if self.ui.checkBox_entity_tree_sticky_selection.checkState().value else "false" self._qsettings.setValue("appSettings/stickySelection", sticky_selection) + hide_empty_classes = "true" if self.ui.checkBox_hide_empty_classes.checkState().value else "false" + self._qsettings.setValue("appSettings/hideEmptyClasses", hide_empty_classes) smooth_zoom = "true" if self.ui.checkBox_smooth_entity_graph_zoom.checkState().value else "false" self._qsettings.setValue("appSettings/smoothEntityGraphZoom", smooth_zoom) smooth_rotation = "true" if self.ui.checkBox_smooth_entity_graph_rotation.checkState().value else "false" self._qsettings.setValue("appSettings/smoothEntityGraphRotation", smooth_rotation) - relationship_items_follow = "true" if self.ui.checkBox_relationship_items_follow.checkState().value else "false" - self._qsettings.setValue("appSettings/relationshipItemsFollow", relationship_items_follow) - auto_expand_objects = "true" if self.ui.checkBox_auto_expand_objects.checkState().value else "false" - self._qsettings.setValue("appSettings/autoExpandObjects", auto_expand_objects) + auto_expand_entities = "true" if self.ui.checkBox_auto_expand_entities.checkState().value else "false" + self._qsettings.setValue("appSettings/autoExpandObjects", auto_expand_entities) + snap_entities = "true" if self.ui.checkBox_snap_entities.checkState().value else "false" + self._qsettings.setValue("appSettings/snapEntities", snap_entities) merge_dbs = "true" if self.ui.checkBox_merge_dbs.checkState().value else "false" self._qsettings.setValue("appSettings/mergeDBs", merge_dbs) db_editor_show_undo = str(self.ui.checkBox_db_editor_show_undo.checkState().value) self._qsettings.setValue("appSettings/dbEditorShowUndo", db_editor_show_undo) + max_ent_dim_count = str(self.ui.spinBox_layout_algo_max_iterations.value()) + self._qsettings.setValue("appSettings/maxEntityDimensionCount", max_ent_dim_count) + build_iters = str(self.ui.spinBox_layout_algo_max_iterations.value()) + self._qsettings.setValue("appSettings/layoutAlgoBuildIterations", build_iters) + spread_factor = str(self.ui.spinBox_layout_algo_spread_factor.value()) + self._qsettings.setValue("appSettings/layoutAlgoSpreadFactor", spread_factor) + neg_weight_exp = str(self.ui.spinBox_layout_algo_neg_weight_exp.value()) + self._qsettings.setValue("appSettings/layoutAlgoNegWeightExp", neg_weight_exp) return True def update_ui(self): super().update_ui() - auto_expand_objects = self._qsettings.value("appSettings/autoExpandObjects", defaultValue="true") == "true" + hide_empty_classes = self._qsettings.value("appSettings/hideEmptyClasses", defaultValue="false") == "true" + auto_expand_entities = self._qsettings.value("appSettings/autoExpandObjects", defaultValue="true") == "true" merge_dbs = self._qsettings.value("appSettings/mergeDBs", defaultValue="true") == "true" - self.set_auto_expand_objects(auto_expand_objects) + self.set_hide_empty_classes(hide_empty_classes) + self.set_auto_expand_entities(auto_expand_entities) self.set_merge_dbs(merge_dbs) @Slot(bool) - def set_auto_expand_objects(self, checked=False): + def set_hide_empty_classes(self, checked=False): for db_editor in self.db_mngr.get_all_spine_db_editors(): - db_editor.ui.graphicsView.set_auto_expand_objects(checked) + db_editor.entity_tree_model.hide_empty_classes = checked + + @Slot(bool) + def set_auto_expand_entities(self, checked=False): + self._set_graph_property("auto_expand_entities", checked) @Slot(bool) def set_merge_dbs(self, checked=False): + self._set_graph_property("merge_dbs", checked) + + @Slot(bool) + def set_snap_entities(self, checked=False): + self._set_graph_property("snap_entities", checked) + + @Slot(int) + def set_max_entity_dimension_count(self, value=None): + self._set_graph_property("max_entity_dimension_count", value) + + @Slot(int) + def set_build_iters(self, value=None): + self._set_graph_property("build_iters", value) + + @Slot(int) + def set_spread_factor(self, value=None): + self._set_graph_property("spread_factor", value) + + @Slot(int) + def set_neg_weight_exp(self, value=None): + self._set_graph_property("neg_weight_exp", value) + + def _set_graph_property(self, name, value): for db_editor in self.db_mngr.get_all_spine_db_editors(): - db_editor.ui.graphicsView.set_merge_dbs(checked) + db_editor.ui.graphicsView.set_property(name, value) class SpineDBEditorSettingsWidget(SpineDBEditorSettingsMixin, SettingsWidgetBase): @@ -305,6 +359,7 @@ def connect_signals(self): self.ui.comboBox_julia_kernel.view().customContextMenuRequested.connect( self.show_julia_kernel_context_menu_on_combobox_list ) + self.ui.lineEdit_conda_path.textChanged.connect(self._refresh_python_kernels) self.ui.toolButton_browse_work.clicked.connect(self.browse_work_path) self.ui.toolButton_bg_color.clicked.connect(self.show_color_dialog) self.ui.radioButton_bg_grid.clicked.connect(self.update_scene_bg) @@ -447,7 +502,8 @@ def make_python_kernel(self, _=False): """Makes a Python kernel for Jupyter Console based on selected Python interpreter. If a kernel using this Python interpreter already exists, sets that kernel selected in the comboBox.""" python_exe = self.ui.lineEdit_python_path.text().strip() - python_exe = resolve_python_interpreter(python_exe) + if not python_exe: + python_exe = resolve_current_python_interpreter() python_kernel = _get_kernel_name_by_exe(python_exe, self._python_kernel_model) if not python_kernel: mpke = MiniPythonKernelEditor(self, python_exe) @@ -465,7 +521,8 @@ def make_julia_kernel(self, _=False): If a kernel using the selected Julia executable and project already exists, sets that kernel selected in the comboBox.""" use_julia_jupyter_console, julia_exe, julia_project, julia_kernel = self._get_julia_settings() - julia_exe = resolve_julia_executable(julia_exe) + if not julia_exe: + julia_exe = resolve_default_julia_executable() julia_kernel = _get_kernel_name_by_exe(julia_exe, self._julia_kernel_model) if julia_kernel: # Kernel with matching executable found match = _selected_project_matches_kernel_project(julia_kernel, julia_project, self._julia_kernel_model) @@ -574,7 +631,7 @@ def browse_work_path(self, _=False): """Open file browser where user can select the path to wanted work directory.""" # noinspection PyCallByClass, PyTypeChecker, PyArgumentList answer = QFileDialog.getExistingDirectory(self, "Select Work Directory", home_dir()) - if answer == '': # Cancel button clicked + if answer == "": # Cancel button clicked return selected_path = os.path.abspath(answer) self.ui.lineEdit_work_dir.setText(selected_path) @@ -622,7 +679,7 @@ def update_items_path(self, checked=False): @Slot(bool) def set_toolbar_colored_icons(self, checked=False): - self._toolbox.main_toolbar.set_colored_icons(checked) + self._toolbox.set_toolbar_colored_icons(checked) @Slot(bool) def _update_properties_widget(self, _checked=False): @@ -706,14 +763,14 @@ def read_settings(self): self.ui.radioButton_use_julia_jupyter_console.setChecked(True) else: self.ui.radioButton_use_julia_basic_console.setChecked(True) - self.ui.lineEdit_julia_path.setPlaceholderText(resolve_julia_executable("")) + self.ui.lineEdit_julia_path.setPlaceholderText(resolve_default_julia_executable()) self.ui.lineEdit_julia_path.setText(julia_path) self.ui.lineEdit_julia_project_path.setText(julia_project_path) if use_python_jupyter_console == 2: self.ui.radioButton_use_python_jupyter_console.setChecked(True) else: self.ui.radioButton_use_python_basic_console.setChecked(True) - self.ui.lineEdit_python_path.setPlaceholderText(resolve_python_interpreter("")) + self.ui.lineEdit_python_path.setPlaceholderText(resolve_current_python_interpreter()) self.ui.lineEdit_python_path.setText(python_path) conda_placeholder_txt = resolve_conda_executable("") if conda_placeholder_txt: @@ -880,6 +937,8 @@ def save_settings(self): self._qsettings.setValue("appSettings/pythonKernel", python_kernel) # Conda conda_exe = self.ui.lineEdit_conda_path.text().strip() + if not is_valid_conda_executable(conda_exe): + conda_exe = "" self._qsettings.setValue("appSettings/condaPath", conda_exe) # Work directory work_dir = self.ui.lineEdit_work_dir.text().strip() @@ -929,12 +988,12 @@ def _save_engine_settings(self): return True def _get_julia_settings(self): + """Returns current Julia execution settings in Settings->Tools widget.""" use_julia_jupyter_console = "2" if self.ui.radioButton_use_julia_jupyter_console.isChecked() else "0" julia_exe = self.ui.lineEdit_julia_path.text().strip() julia_project = self.ui.lineEdit_julia_project_path.text().strip() - if self.ui.comboBox_julia_kernel.currentIndex() == 0: - julia_kernel = "" - else: + julia_kernel = "" + if self.ui.comboBox_julia_kernel.currentIndex() != 0: julia_kernel = self.ui.comboBox_julia_kernel.currentText() return use_julia_jupyter_console, julia_exe, julia_project, julia_kernel @@ -1000,6 +1059,7 @@ def start_fetching_julia_kernels(self): self.julia_kernel_fetcher = KernelFetcher(conda_path, fetch_mode=4) self.julia_kernel_fetcher.kernel_found.connect(self.add_julia_kernel) self.julia_kernel_fetcher.finished.connect(self.restore_saved_julia_kernel) + self.julia_kernel_fetcher.finished.connect(self.julia_kernel_fetcher.deleteLater) self.julia_kernel_fetcher.start() @Slot() @@ -1036,18 +1096,23 @@ def restore_saved_julia_kernel(self): self.ui.comboBox_julia_kernel.setCurrentIndex(0) else: self.ui.comboBox_julia_kernel.setCurrentIndex(ind) + self.julia_kernel_fetcher = None - def start_fetching_python_kernels(self): + def start_fetching_python_kernels(self, conda_path_updated=False): """Starts a thread for fetching Python kernels.""" if self.python_kernel_fetcher is not None and self.python_kernel_fetcher.isRunning(): # Trying to start a new thread when the old one is still running return self._python_kernel_model.clear() self.ui.comboBox_python_kernel.addItem("Select Python kernel...") - conda_path = self._toolbox.qsettings().value("appSettings/condaPath", defaultValue="") + if not conda_path_updated: + conda_path = self._toolbox.qsettings().value("appSettings/condaPath", defaultValue="") + else: + conda_path = self.ui.lineEdit_conda_path.text().strip() self.python_kernel_fetcher = KernelFetcher(conda_path, fetch_mode=2) self.python_kernel_fetcher.kernel_found.connect(self.add_python_kernel) self.python_kernel_fetcher.finished.connect(self.restore_saved_python_kernel) + self.python_kernel_fetcher.finished.connect(self.python_kernel_fetcher.deleteLater) self.python_kernel_fetcher.start() @Slot() @@ -1065,6 +1130,7 @@ def add_python_kernel(self, kernel_name, resource_dir, conda, icon, deats): item = QStandardItem(kernel_name) item.setIcon(icon) item.setToolTip(resource_dir) + deats["is_conda"] = conda item.setData(deats) self._python_kernel_model.appendRow(item) @@ -1084,6 +1150,20 @@ def restore_saved_python_kernel(self): self.ui.comboBox_python_kernel.setCurrentIndex(0) else: self.ui.comboBox_python_kernel.setCurrentIndex(ind) + self.python_kernel_fetcher = None + + @Slot(str) + def _refresh_python_kernels(self, conda_path): + """Refreshes Python kernels when the conda line edit points to a valid conda + executable or when the line edit is cleared. + + Args: + conda_path (str): Text in line edit after it's been changed. + """ + if conda_path and not is_valid_conda_executable(conda_path): + return + self.newly_created_kernel = self.ui.comboBox_python_kernel.currentText() + self.start_fetching_python_kernels(conda_path_updated=True) def closeEvent(self, ev): self.stop_fetching_julia_kernels() diff --git a/spinetoolbox/widgets/statusbars.py b/spinetoolbox/widgets/statusbars.py index bf39dd3af..e703505f4 100644 --- a/spinetoolbox/widgets/statusbars.py +++ b/spinetoolbox/widgets/statusbars.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Functions to make and handle QStatusBars. -""" +"""Functions to make and handle QStatusBars.""" from PySide6.QtCore import Slot from PySide6.QtWidgets import QStatusBar, QToolButton, QMenu from PySide6.QtGui import QAction diff --git a/spinetoolbox/widgets/time_pattern_editor.py b/spinetoolbox/widgets/time_pattern_editor.py index a111ec2f7..88a35d0c6 100644 --- a/spinetoolbox/widgets/time_pattern_editor.py +++ b/spinetoolbox/widgets/time_pattern_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,13 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -An editor widget for editing a time pattern type (relationship) parameter values. -""" - +"""An editor widget for editing a time pattern type (relationship) parameter values.""" from PySide6.QtCore import QPoint, Qt, Slot -from PySide6.QtWidgets import QWidget - +from PySide6.QtWidgets import QHeaderView, QWidget from spinedb_api import TimePattern from ..helpers import inquire_index_name from ..mvcmodels.time_pattern_model import TimePatternModel @@ -40,7 +37,9 @@ def __init__(self, parent=None): self._ui.pattern_edit_table.setModel(self._model) self._ui.pattern_edit_table.setContextMenuPolicy(Qt.CustomContextMenu) self._ui.pattern_edit_table.customContextMenuRequested.connect(self._show_table_context_menu) - self._ui.pattern_edit_table.horizontalHeader().sectionDoubleClicked.connect(self._open_header_editor) + header = self._ui.pattern_edit_table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.sectionDoubleClicked.connect(self._open_header_editor) @Slot(QPoint) def _show_table_context_menu(self, position): diff --git a/spinetoolbox/widgets/time_series_fixed_resolution_editor.py b/spinetoolbox/widgets/time_series_fixed_resolution_editor.py index 4c26f3170..1f0c0c6a7 100644 --- a/spinetoolbox/widgets/time_series_fixed_resolution_editor.py +++ b/spinetoolbox/widgets/time_series_fixed_resolution_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains logic for the fixed step time series editor widget. -""" - +"""Contains logic for the fixed step time series editor widget.""" from datetime import datetime from PySide6.QtCore import QDate, QModelIndex, QPoint, Qt, Slot -from PySide6.QtWidgets import QCalendarWidget, QWidget - +from PySide6.QtGui import QFontMetrics +from PySide6.QtWidgets import QCalendarWidget, QHeaderView, QWidget from spinedb_api import ( duration_to_relativedelta, ParameterValueFormatError, @@ -33,17 +31,17 @@ def _resolution_to_text(resolution): """Converts a list of durations into a string of comma-separated durations.""" if len(resolution) == 1: return relativedelta_to_duration(resolution[0]) - affix = '' - text = '' + affix = "" + text = "" for r in resolution: text = text + affix + relativedelta_to_duration(r) - affix = ', ' + affix = ", " return text def _text_to_resolution(text): """Converts a comma-separated string of durations into a resolution array.""" - return [token.strip() for token in text.split(',')] + return [token.strip() for token in text.split(",")] class TimeSeriesFixedResolutionEditor(QWidget): @@ -74,6 +72,8 @@ def __init__(self, parent=None): self._ui.setupUi(self) self._ui.start_time_edit.setText(str(initial_value.start)) self._ui.start_time_edit.editingFinished.connect(self._start_time_changed) + edit_min_width = self._ui.start_time_edit.fontMetrics().horizontalAdvance("YYYY-DD-MMTHH:MM:SS") + self._ui.start_time_edit.setMinimumWidth(edit_min_width + 10) self._ui.calendar_button.clicked.connect(self._show_calendar) self._ui.resolution_edit.setText(_resolution_to_text(initial_value.resolution)) self._ui.resolution_edit.editingFinished.connect(self._resolution_changed) @@ -81,7 +81,9 @@ def __init__(self, parent=None): self._ui.time_series_table.setModel(self._model) self._ui.time_series_table.setContextMenuPolicy(Qt.CustomContextMenu) self._ui.time_series_table.customContextMenuRequested.connect(self._show_table_context_menu) - self._ui.time_series_table.horizontalHeader().sectionDoubleClicked.connect(self._open_header_editor) + header = self._ui.time_series_table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.sectionDoubleClicked.connect(self._open_header_editor) self._ui.ignore_year_check_box.setChecked(self._model.value.ignore_year) self._ui.ignore_year_check_box.toggled.connect(self._model.set_ignore_year) self._ui.repeat_check_box.setChecked(self._model.value.repeat) @@ -159,7 +161,7 @@ def _update_plot(self, topLeft=None, bottomRight=None, roles=None): """Updated the plot.""" self._ui.plot_widget.canvas.axes.cla() add_time_series_plot(self._ui.plot_widget, self._model.value) - self._ui.plot_widget.canvas.axes.tick_params(axis='x', labelrotation=30) + self._ui.plot_widget.canvas.axes.tick_params(axis="x", labelrotation=30) self._ui.plot_widget.canvas.draw() def value(self): diff --git a/spinetoolbox/widgets/time_series_variable_resolution_editor.py b/spinetoolbox/widgets/time_series_variable_resolution_editor.py index c6cbfebb2..7994aa557 100644 --- a/spinetoolbox/widgets/time_series_variable_resolution_editor.py +++ b/spinetoolbox/widgets/time_series_variable_resolution_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains logic for the variable resolution time series editor widget. -""" - +"""Contains logic for the variable resolution time series editor widget.""" from PySide6.QtCore import QModelIndex, QPoint, Qt, Slot -from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QHeaderView, QWidget from spinedb_api import TimeSeriesVariableResolution from ..plotting import add_time_series_plot from ..mvcmodels.time_series_model_variable_resolution import TimeSeriesModelVariableResolution @@ -51,7 +49,9 @@ def __init__(self, parent=None): self._ui.time_series_table.setModel(self._model) self._ui.time_series_table.setContextMenuPolicy(Qt.CustomContextMenu) self._ui.time_series_table.customContextMenuRequested.connect(self._show_table_context_menu) - self._ui.time_series_table.horizontalHeader().sectionDoubleClicked.connect(self._open_header_editor) + header = self._ui.time_series_table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) + header.sectionDoubleClicked.connect(self._open_header_editor) self._ui.ignore_year_check_box.setChecked(self._model.value.ignore_year) self._ui.ignore_year_check_box.toggled.connect(self._model.set_ignore_year) self._ui.repeat_check_box.setChecked(self._model.value.repeat) @@ -82,7 +82,7 @@ def _update_plot(self, topLeft=None, bottomRight=None, roles=None): """Updates the plot widget.""" self._ui.plot_widget.canvas.axes.cla() add_time_series_plot(self._ui.plot_widget, self._model.value) - self._ui.plot_widget.canvas.axes.tick_params(axis='x', labelrotation=30) + self._ui.plot_widget.canvas.axes.tick_params(axis="x", labelrotation=30) self._ui.plot_widget.canvas.draw() def value(self): diff --git a/spinetoolbox/widgets/toolbars.py b/spinetoolbox/widgets/toolbars.py index a68c541f1..cae802342 100644 --- a/spinetoolbox/widgets/toolbars.py +++ b/spinetoolbox/widgets/toolbars.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,54 @@ # this program. If not, see . ###################################################################################################################### -""" -Functions to make and handle QToolBars. -""" - -from PySide6.QtCore import Qt, Slot -from PySide6.QtWidgets import QToolBar, QLabel -from PySide6.QtGui import QIcon, QPainter +"""Functions to make and handle QToolBars.""" +from PySide6.QtCore import Qt, Slot, QModelIndex, QPoint, QSize +from PySide6.QtWidgets import QToolBar, QToolButton, QMenu, QWidget +from PySide6.QtGui import QIcon, QPainter, QFontMetrics, QPainterPath from ..helpers import make_icon_toolbar_ss, ColoredIcon, CharIconEngine -from .project_item_drag import NiceButton, ProjectItemButton, ProjectItemSpecButton, ProjectItemSpecArray +from .project_item_drag import NiceButton, ProjectItemButton, ProjectItemSpecButton + + +class _TitleWidget(QWidget): + def __init__(self, title, toolbar): + self._toolbar = toolbar + super().__init__() + self._title = title + font = self.font() + font.setPointSize(8) + self.setFont(font) + self.setMaximumSize(1, 1) + fm = QFontMetrics(self.font()) + height = fm.height() + self.margin = 0.25 * height + self.desired_height = height + 2 * self.margin + self.desired_width = fm.horizontalAdvance(self._title) + 2 * self.margin + + def sizeHint(self): + if self._toolbar.orientation() == Qt.Horizontal: + return QSize(1, 1) + return QSize(self.desired_width, self.desired_height) + + def paintEvent(self, ev): + self.setFixedSize(self.sizeHint()) + painter = QPainter(self) + self.do_paint(painter) + painter.end() + + def do_paint(self, painter, x=None): + if x is None: + x = self.margin + pos = QPoint(x, self.desired_height - self.margin) + painter.save() + painter.setRenderHint(QPainter.Antialiasing) + painter.setFont(self.font()) + path = QPainterPath() + path.addText(pos, self.font(), self._title) + painter.setPen(Qt.white) + painter.drawPath(path) + painter.setPen(Qt.black) + painter.drawText(pos, self._title) + painter.restore() class ToolBar(QToolBar): @@ -30,8 +70,33 @@ def __init__(self, name, toolbox): toolbox (ToolboxUI): Toolbox main window """ super().__init__(name, parent=toolbox) + self._name = name self.setObjectName(name.replace(" ", "_")) self._toolbox = toolbox + self.addWidget(_TitleWidget(self.name(), self)) + + def name(self): + return self._name + + def paintEvent(self, ev): + super().paintEvent(ev) + if self.orientation() == Qt.Vertical: + return + layout = self.layout() + title_pos_x = ( + (w, layout.itemAt(i + 1).widget().pos().x()) + for i in range(layout.count()) + if isinstance((w := layout.itemAt(i).widget()), _TitleWidget) + ) + painter = QPainter(self) + for w, x in title_pos_x: + w.do_paint(painter, x) + painter.end() + + def set_colored_icons(self, colored): + for w in self.buttons(): + w.set_colored_icons(colored) + self.update() def set_color(self, color): """Sets toolbar's background color. @@ -39,7 +104,7 @@ def set_color(self, color): Args: color (QColor): background color """ - raise NotImplementedError() + self.setStyleSheet(make_icon_toolbar_ss(color)) def set_project_actions_enabled(self, enabled): """Enables or disables project related actions. @@ -47,9 +112,64 @@ def set_project_actions_enabled(self, enabled): Args: enabled (bool): True to enable actions, False to disable """ - for child_type in (ProjectItemButton, ProjectItemSpecButton): - for button in self.findChildren(child_type): - button.setEnabled(enabled) + for button in self.findChildren(NiceButton): + button.setEnabled(enabled) + + def _process_tool_button(self, button): + button.set_orientation(self.orientation()) + self.orientationChanged.connect(button.set_orientation) + + def _insert_tool_button(self, before, button): + """Inserts button into the toolbar. + + Args: + before (QWidget): insert before this widget + button (QToolButton): button to add + + Returns: + QAction + """ + self._process_tool_button(button) + return self.insertWidget(before, button) + + def _add_tool_button(self, button): + """Adds a button to the toolbar. + + Args: + button (QToolButton): button to add + + Returns: + QAction + """ + self._process_tool_button(button) + return self.addWidget(button) + + def _make_tool_button(self, icon, text, slot=None, tip=None): + """Makes a new tool button and adds it to the toolbar. + + Args: + icon (QIcon): button's icon + text (str): button's text + slot (Callable): slot where to connect button's clicked signal + tip (str): button's tooltip + + Returns: + QToolButton: created button + """ + button = NiceButton() + button.setIcon(icon) + button.setText(text) + button.setToolTip(f"

{tip}

") + if slot is not None: + button.clicked.connect(slot) + self._add_tool_button(button) + return button + + def _icon_from_factory(self, factory): + colored = self._toolbox.qsettings().value("appSettings/colorToolbarIcons", defaultValue="false") == "true" + icon_file_name = factory.icon() + icon_color = factory.icon_color().darker(120) + return ColoredIcon(icon_file_name, icon_color, self.iconSize(), colored=colored) class PluginToolBar(ToolBar): @@ -62,10 +182,15 @@ def __init__(self, name, parent): parent (ToolboxUI): QMainWindow instance """ super().__init__(name, parent) # Inherits stylesheet from ToolboxUI - self._name = name self._buttons = {} self._toolbox.specification_model.specification_replaced.connect(self._update_spec_button_name) + def name(self): + return self._name + " plugin" + + def buttons(self): + return self._buttons.values() + def setup(self, plugin_specs, disabled_names): """Sets up the toolbar. @@ -73,21 +198,17 @@ def setup(self, plugin_specs, disabled_names): plugin_specs (dict): mapping from specification name to specification disabled_names (Iterable of str): specifications that should be disabled """ - self.addWidget(PaddingLabel(self._name)) for specs in plugin_specs.values(): for spec in specs: factory = self._toolbox.item_factories[spec.item_type] - icon = QIcon(factory.icon()) + icon = self._icon_from_factory(factory) button = ProjectItemSpecButton(self._toolbox, spec.item_type, icon, spec.name) button.setIconSize(self.iconSize()) if spec.name in disabled_names: button.setEnabled(False) - self.addWidget(button) + self._add_tool_button(button) self._buttons[spec.name] = button - def set_color(self, color): - self.setStyleSheet(make_icon_toolbar_ss(color)) - @Slot(str, str) def _update_spec_button_name(self, old_name, new_name): button = self._buttons.pop(old_name, None) @@ -97,138 +218,125 @@ def _update_spec_button_name(self, old_name, new_name): button.spec_name = new_name -class MainToolBar(ToolBar): - """The main application toolbar: Items | Execute""" +class SpecToolBar(ToolBar): + def __init__(self, parent): + super().__init__("Specifications", parent) # Inherits stylesheet from ToolboxUI + self._actions = {} + self._model = None + + def buttons(self): + return (self.widgetForAction(a) for a in self._actions.values()) + + @Slot(QModelIndex, int, int) + def _insert_specs(self, parent, first, last): + for row in range(first, last + 1): + self._add_spec(row) + + def _add_spec(self, row): + spec = self._model.specification(row) + if spec.plugin: + return + next_row = row + 1 + while True: + next_spec = self._model.specification(next_row) + if next_spec is None or not next_spec.plugin: + break + next_row += 1 + factory = self._toolbox.item_factories[spec.item_type] + icon = self._icon_from_factory(factory) + button = ProjectItemSpecButton(self._toolbox, spec.item_type, icon, spec.name) + button.setIconSize(self.iconSize()) + action = ( + self._insert_tool_button(self._actions[next_spec.name], button) + if next_spec + else self._add_tool_button(button) + ) + self._actions[spec.name] = action + + @Slot(QModelIndex, int, int) + def _remove_specs(self, parent, first, last): + for row in range(first, last + 1): + self._remove_spec(row) + + def _remove_spec(self, row): + spec_name = self._model.index(row, 0).data(Qt.ItemDataRole.DisplayRole) + try: + action = self._actions.pop(spec_name) + self.removeAction(action) + except KeyError: + pass # Happens when Plugins are removed + + @Slot() + def _reset_specs(self): + for action in self._actions.values(): + self.removeAction(action) + self._actions.clear() + for row in range(self._model.rowCount()): + self._add_spec(row) + + def setup(self): + self._model = self._toolbox.specification_model + self._model.rowsInserted.connect(self._insert_specs) + self._model.rowsAboutToBeRemoved.connect(self._remove_specs) + self._model.modelReset.connect(self._reset_specs) + menu = QMenu(self) + for item_type, factory in self._toolbox.item_factories.items(): + if factory.is_deprecated() or not self._toolbox.supports_specification(item_type): + continue + menu.addAction( + item_type, lambda item_type=item_type: self._toolbox.show_specification_form(item_type) + ).setIcon(self._icon_from_factory(factory)) + menu.addSeparator() + menu.addAction("From specification file...", self._toolbox.import_specification).setIcon( + QIcon(CharIconEngine("\uf067", color=Qt.darkGreen)) + ) + button = self._make_tool_button(QIcon(CharIconEngine("\uf067", color=Qt.darkGreen)), "New...") + button.setPopupMode(QToolButton.InstantPopup) + button.setMenu(menu) + + +class ItemsToolBar(ToolBar): + """The base items""" _SEPARATOR = ";;" - def __init__(self, execute_project_action, execute_selection_action, stop_execution_action, parent): + def __init__(self, parent): """ Args: - execute_project_action (QAction): action to execute project - execute_selection_action (QAction): action to execute selected items - stop_execution_action (QAction): action to stop execution parent (ToolboxUI): QMainWindow instance """ - super().__init__("Main Toolbar", parent) # Inherits stylesheet from ToolboxUI - self._execute_project_action = execute_project_action - self.execute_project_button = None - self._execute_selection_action = execute_selection_action - self.execute_selection_button = None - self._stop_execution_action = stop_execution_action - self.stop_execution_button = None + super().__init__("Generic items", parent) # Inherits stylesheet from ToolboxUI self._buttons = [] - self._spec_arrays = [] self._drop_source_action = None self._drop_target_action = None self.setAcceptDrops(True) - def set_project_actions_enabled(self, enabled): - super().set_project_actions_enabled(enabled) - for arr in self._spec_arrays: - arr.update() - - def set_color(self, color): - self.setStyleSheet(make_icon_toolbar_ss(color)) - self.layout().setSpacing(1) - for arr in self._spec_arrays: - arr.set_color(color) + def buttons(self): + return self._buttons def setup(self): self.add_project_item_buttons() - self.add_execute_buttons() def add_project_item_buttons(self): - self.addWidget(PaddingLabel("Items")) - colored = self._toolbox.qsettings().value("appSettings/colorToolbarIcons", defaultValue="false") == "true" icon_ordering = self._toolbox.qsettings().value("appSettings/toolbarIconOrdering", defaultValue="") ordered_item_types = icon_ordering.split(self._SEPARATOR) for item_type in ordered_item_types: factory = self._toolbox.item_factories.get(item_type) if factory is None: continue - self._add_project_item_button(item_type, factory, colored) + self._add_project_item_button(item_type, factory) for item_type, factory in self._toolbox.item_factories.items(): if item_type in ordered_item_types: continue - self._add_project_item_button(item_type, factory, colored) - self._make_tool_button( - QIcon(CharIconEngine("\uf067", color=Qt.darkGreen)), - "From file...", - self._toolbox.import_specification, - tip="Add item specification from file...", - ) + self._add_project_item_button(item_type, factory) - def _add_project_item_button(self, item_type, factory, colored): + def _add_project_item_button(self, item_type, factory): if factory.is_deprecated(): return - icon_file_type = factory.icon() - icon_color = factory.icon_color().darker(120) - icon = ColoredIcon(icon_file_type, icon_color, self.iconSize(), colored=colored) - if not self._toolbox.supports_specification(item_type): - button = ProjectItemButton(self._toolbox, item_type, icon) - button.set_orientation(self.orientation()) - self.orientationChanged.connect(button.set_orientation) - self.addWidget(button) - self._buttons.append(button) - else: - model = self._toolbox.filtered_spec_factory_models.get(item_type) - spec_array = ProjectItemSpecArray(self._toolbox, model, item_type, icon) - spec_array.setOrientation(self.orientation()) - self._spec_arrays.append(spec_array) - self.addWidget(spec_array) - self.orientationChanged.connect(spec_array.setOrientation) - - def set_colored_icons(self, colored): - for w in self._buttons + self._spec_arrays: - w.set_colored_icons(colored) - self.update() - - def _make_tool_button(self, icon, text, slot, tip=None): - """Makes a new tool button and adds it to the toolbar. - - Args: - icon (QIcon): button's icon - text (str): button's text - slot (Callable): slot where to connect button's clicked signal - tip (str): button's tooltip - - Returns: - QToolButton: created button - """ - button = NiceButton() - button.setIcon(icon) - button.setText(text) - button.setToolTip(f"

{tip}

") - button.clicked.connect(slot) + icon = self._icon_from_factory(factory) + button = ProjectItemButton(self._toolbox, item_type, icon) self._add_tool_button(button) - return button - - def _add_tool_button(self, button): - """Adds a button to the toolbar. - - Args: - button (QToolButton): button to add - """ - button.setStyleSheet("QToolButton{padding: 2px}") - button.set_orientation(self.orientation()) - self.orientationChanged.connect(button.set_orientation) - self.addWidget(button) - - def add_execute_buttons(self): - """Adds project execution buttons to the toolbar.""" - self.addSeparator() - self.addWidget(PaddingLabel("Execute")) - self.execute_project_button = NiceButton() - self.execute_project_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) - self.execute_project_button.setDefaultAction(self._execute_project_action) - self._add_tool_button(self.execute_project_button) - self.execute_selection_button = NiceButton() - self.execute_selection_button.setDefaultAction(self._execute_selection_action) - self._add_tool_button(self.execute_selection_button) - self.stop_execution_button = NiceButton() - self.stop_execution_button.setDefaultAction(self._stop_execution_action) - self._add_tool_button(self.stop_execution_button) + self._buttons.append(button) def dragLeaveEvent(self, event): event.accept() @@ -246,8 +354,11 @@ def dragMoveEvent(self, event): self.update() def dropEvent(self, event): - if self._drop_target_action != self._drop_source_action: - self.insertAction(self._drop_target_action, self._drop_source_action) + if self._drop_source_action is not None: + if self._drop_target_action is not None: + self.insertAction(self._drop_target_action, self._drop_source_action) + else: + self.addAction(self._drop_source_action) self._drop_source_action = None self._drop_target_action = None self.update() @@ -268,7 +379,7 @@ def _update_drop_actions(self, event): return while target.parent() != self: target = target.parent() - if not isinstance(target, (ProjectItemButton, ProjectItemSpecArray)): + if not isinstance(target, ProjectItemButton): return while source.parent() != self: source = source.parent() @@ -277,47 +388,63 @@ def _update_drop_actions(self, event): else: after = target.geometry().center().y() < event.position().toPoint().y() actions = self.actions() - source_action = next((a for a in actions if self.widgetForAction(a) == source)) + self._drop_source_action = next((a for a in actions if self.widgetForAction(a) == source)) target_index = next((i for i, a in enumerate(actions) if self.widgetForAction(a) == target)) if after: target_index += 1 - target_action = actions[target_index] - self._drop_source_action = source_action - self._drop_target_action = target_action + try: + self._drop_target_action = actions[target_index] + except IndexError: + self._drop_target_action = None def paintEvent(self, ev): """Draw a line as drop indicator.""" super().paintEvent(ev) - if self._drop_target_action is None: + if self._drop_source_action is None: return painter = QPainter(self) painter.drawLine(*self._drop_line()) # Draw line from (x1, y1) to (x2, y2) painter.end() def _drop_line(self): - widget = self.widgetForAction(self._drop_target_action) - geom = widget.geometry() + target_widget = self.widgetForAction(self._drop_target_action) if self._drop_target_action is not None else None + last_widget = self.widgetForAction(self.actions()[-1]) margins = self.layout().contentsMargins() if self.orientation() == Qt.Orientation.Horizontal: - x = geom.left() - 1 - return x, margins.left(), x, self.height() - margins.top() - y = geom.top() - 1 - return margins.top(), y, self.width() - margins.left(), y + x = (target_widget.geometry().left() if target_widget is not None else last_widget.geometry().right()) - 1 + widget = target_widget or last_widget + return (x, widget.geometry().top(), x, self.height() - margins.bottom()) + y = (target_widget.geometry().top() if target_widget is not None else last_widget.geometry().bottom()) - 1 + return margins.left(), y, self.width() - margins.right(), y def icon_ordering(self): item_types = [] for a in self.actions(): w = self.widgetForAction(a) - if not isinstance(w, (ProjectItemButton, ProjectItemSpecArray)): + if not isinstance(w, ProjectItemButton): continue item_types.append(w.item_type) return self._SEPARATOR.join(item_types) -class PaddingLabel(QLabel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - font = self.font() - font.setPointSize(8) - self.setFont(font) - self.setStyleSheet("QLabel{padding: 2px}") +class ExecuteToolBar(ToolBar): + def __init__(self, parent): + """ + Args: + parent (ToolboxUI): QMainWindow instance + """ + super().__init__("Execute", parent) # Inherits stylesheet from ToolboxUI + + def setup(self): + self._add_buttons() + + def _add_button_from_action(self, action): + button = NiceButton() + button.setDefaultAction(action) + self._add_tool_button(button) + + def _add_buttons(self): + """Adds buttons to the toolbar.""" + self._add_button_from_action(self._toolbox.ui.actionExecute_project) + self._add_button_from_action(self._toolbox.ui.actionExecute_selection) + self._add_button_from_action(self._toolbox.ui.actionStop_execution) diff --git a/tests/__init__.py b/tests/__init__.py index 1e235f926..f7994c31d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Init file for tests package. Intentionally empty. -""" +"""Init file for tests package. Intentionally empty.""" diff --git a/tests/mock_helpers.py b/tests/mock_helpers.py index 590c756bc..1c610822e 100644 --- a/tests/mock_helpers.py +++ b/tests/mock_helpers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,16 +10,12 @@ # this program. If not, see . ###################################################################################################################### -""" -Classes and functions that can be shared among unit test modules. -""" +"""Classes and functions that can be shared among unit test modules.""" from contextlib import contextmanager from unittest import mock - -from PySide6.QtCore import QModelIndex +from PySide6.QtCore import QModelIndex, Qt from PySide6.QtWidgets import QApplication import spinetoolbox.resources_icons_rc # pylint: disable=unused-import -from spinetoolbox.fetch_parent import FlexibleFetchParent from spinetoolbox.ui_main import ToolboxUI from spinetoolbox.spine_db_manager import SpineDBManager @@ -107,10 +104,32 @@ def add_ds(project, item_factories, name, x=0.0, y=0.0): DataStore: added project item """ item_dict = {name: {"type": "Data Store", "description": "", "url": dict(), "x": x, "y": y}} - project.restore_project_items(item_dict, item_factories, silent=True) + project.restore_project_items(item_dict, item_factories) return project.get_item(name) +def add_dc_trough_undo_stack(toolbox, name, x=0, y=0, file_refs=None): + """Helper function to create a Data Connection to currently opened project through the undo stack. + + Args: + toolbox (ToolboxUI): The toolbox main UI + name (str): item's name + x (float): item's x coordinate + y (float): item's y coordinate + file_refs (list): File references + + Returns: + DataConnection: added project item + """ + frefs = list() if not file_refs else file_refs + item_dict = {name: {"type": "Data Connection", "description": "", "references": frefs, "x": x, "y": y}} + if toolbox: # This way the changes are pushed to the undo stack of ToolboxUI + toolbox.add_project_items(item_dict) + else: + toolbox._project.restore_project_items(item_dict, toolbox.item_factories) + return toolbox._project.get_item(name) + + def add_dc(project, item_factories, name, x=0, y=0, file_refs=None): """Helper function to create a Data Connection to given project. @@ -127,7 +146,7 @@ def add_dc(project, item_factories, name, x=0, y=0, file_refs=None): """ frefs = list() if not file_refs else file_refs item_dict = {name: {"type": "Data Connection", "description": "", "references": frefs, "x": x, "y": y}} - project.restore_project_items(item_dict, item_factories, silent=True) + project.restore_project_items(item_dict, item_factories) return project.get_item(name) @@ -148,7 +167,7 @@ def add_tool(project, item_factories, name, tool_spec="", x=0, y=0): item = { name: {"type": "Tool", "description": "", "specification": tool_spec, "execute_in_work": False, "x": x, "y": y} } - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -166,7 +185,7 @@ def add_view(project, item_factories, name, x=0, y=0): View: added project item """ item = {name: {"type": "View", "description": "", "x": x, "y": y}} - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -184,7 +203,7 @@ def add_importer(project, item_factories, name, x=0, y=0): Importer: added project item """ item = {name: {"type": "Importer", "description": "", "specification": "", "x": x, "y": y}} - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -202,7 +221,7 @@ def add_data_transformer(project, item_factories, name, x=0, y=0): DataTransformer: added project item """ item = {name: {"type": "Data Transformer", "description": "", "x": x, "y": y, "specification": ""}} - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -220,7 +239,7 @@ def add_exporter(project, item_factories, name, x=0, y=0): Exporter: added project item """ item = {name: {"type": "Exporter", "description": "", "x": x, "y": y, "specification": None}} - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -238,7 +257,7 @@ def add_merger(project, item_factories, name, x=0, y=0): Merger: added project item """ item = {name: {"type": "Merger", "description": "", "x": x, "y": y}} - project.restore_project_items(item, item_factories, silent=True) + project.restore_project_items(item, item_factories) return project.get_item(name) @@ -286,43 +305,14 @@ def start(self, *args, **kwargs): class TestSpineDBManager(SpineDBManager): - # FIXME: Needed? - def fetch_all(self, db_map): - worker = self._get_worker(db_map) - for item_type in db_map.ITEM_TYPES: - parent = FlexibleFetchParent(item_type) - if worker.can_fetch_more(parent): - worker.fetch_more(parent) - qApp.processEvents() - - def get_db_map(self, *args, **kwargs): - with mock.patch("spinetoolbox.spine_db_worker.QtBasedThreadPoolExecutor") as mock_executor: - mock_executor.return_value = _MockExecutor() - return super().get_db_map(*args, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, synchronous=True) def can_fetch_more(self, db_map, parent): - parent.add_item = lambda item, db_map: parent.handle_items_added({db_map: [item]}) - parent.update_item = lambda item, db_map: parent.handle_items_updated({db_map: [item]}) - parent.remove_item = lambda item, db_map: parent.handle_items_removed({db_map: [item]}) + parent.apply_changes_immediately() return super().can_fetch_more(db_map, parent) -class _MockExecutor: - def submit(self, fn, *args, **kwargs): - return _MockFuture(result=fn(*args, **kwargs)) - - def shutdown(self): - pass - - -class _MockFuture: - def __init__(self, result): - self._result = result - - def result(self): - return self._result - - @contextmanager def q_object(o): """Deletes given QObject after the context runs out. @@ -350,3 +340,43 @@ def model_data_to_dict(model, parent=QModelIndex()): row_data.append({index.data(): child_data} if child_data else index.data()) rows.append(row_data) return rows + + +def model_data_to_table(model, parent=QModelIndex(), role=Qt.ItemDataRole.DisplayRole): + """Puts model data into Python table. + + Args: + model (QAbstractItemModel): model to process + parent (QModelIndex): parent index + role (Qt.ItemDataRole): data role + + Returns: + list of list: model data + """ + data = [] + for row in range(model.rowCount()): + data.append([model.index(row, column, parent).data(role) for column in range(model.columnCount())]) + return data + + +def fetch_model(model): + while model.canFetchMore(QModelIndex()): + model.fetchMore(QModelIndex()) + qApp.processEvents() + + +class FakeDataStore: + def __init__(self, n): + self.name = n + + def item_type(self): + return "Data Store" + + def sql_alchemy_url(self): + return f"{self.name}_sql_alchemy_url" + + def is_url_validated(self): + return True + + def tear_down(self): + return True diff --git a/tests/mvcmodels/__init__.py b/tests/mvcmodels/__init__.py index 3913337cf..199930a19 100644 --- a/tests/mvcmodels/__init__.py +++ b/tests/mvcmodels/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Init file for tests.mvcmodels package. Intentionally empty. -""" +"""Init file for tests.mvcmodels package. Intentionally empty.""" diff --git a/tests/mvcmodels/test_ArrayModel.py b/tests/mvcmodels/test_ArrayModel.py index b800ef49f..ef22bdb17 100644 --- a/tests/mvcmodels/test_ArrayModel.py +++ b/tests/mvcmodels/test_ArrayModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the ArrayModel class. -""" +"""Unit tests for the ArrayModel class.""" import unittest from PySide6.QtCore import QObject, Qt from spinedb_api import Array @@ -123,5 +122,5 @@ def test_set_index_type_via_header(self): self.assertEqual(model.array().index_name, "new index") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_FileListModel.py b/tests/mvcmodels/test_FileListModel.py index 97e7449fe..a714317af 100644 --- a/tests/mvcmodels/test_FileListModel.py +++ b/tests/mvcmodels/test_FileListModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Items. # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for FileListModel class. -""" - +"""Unit tests for FileListModel class.""" import unittest from PySide6.QtWidgets import QApplication from pathlib import Path diff --git a/tests/mvcmodels/test_FilterCheckboxList.py b/tests/mvcmodels/test_FilterCheckboxList.py index 3e9863686..6eb043b15 100644 --- a/tests/mvcmodels/test_FilterCheckboxList.py +++ b/tests/mvcmodels/test_FilterCheckboxList.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for FilterCheckboxListModel class. -""" - +"""Unit tests for FilterCheckboxListModel class.""" import unittest from unittest import mock from PySide6.QtCore import Qt @@ -28,7 +26,7 @@ def setUpClass(cls): def setUp(self): self.model = SimpleFilterCheckboxListModel(None) - self.data = ['a', 'aa', 'aaa', 'b', 'bb', 'bbb'] + self.data = ["a", "aa", "aaa", "b", "bb", "bbb"] def test_set_list(self): self.model.set_list(self.data) @@ -43,7 +41,7 @@ def test_is_all_selected_when_all_selected(self): def test_is_all_selected_when_not_all_selected(self): self.model.set_list(self.data) - self.model._selected.discard('a') + self.model._selected.discard("a") self.assertFalse(self.model._check_all_selected()) def test_is_all_selected_when_not_empty_selected(self): @@ -52,7 +50,7 @@ def test_is_all_selected_when_not_empty_selected(self): self.assertFalse(self.model._check_all_selected()) def test_add_item_with_select_without_filter(self): - new_item = ['aaaa'] + new_item = ["aaaa"] self.model.set_list(self.data) with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginInsertRows" @@ -66,7 +64,7 @@ def test_add_item_with_select_without_filter(self): self.assertEqual(self.model._data_set, set(self.data + new_item)) def test_add_item_without_select_without_filter(self): - new_item = ['aaaa'] + new_item = ["aaaa"] self.model.set_list(self.data) with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginInsertRows" @@ -91,12 +89,12 @@ def test_click_selected_item(self): with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(2, 0) self.model._handle_index_clicked(index) - self.assertEqual(self.model._selected, set(self.data).difference({'a'})) + self.assertEqual(self.model._selected, set(self.data).difference({"a"})) self.assertFalse(self.model._all_selected) def test_click_unselected_item(self): self.model.set_list(self.data) - self.model._selected.discard('a') + self.model._selected.discard("a") with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(2, 0) self.model._handle_index_clicked(index) @@ -132,17 +130,17 @@ def test_click_select_all_when_not_all_selected(self): def test_set_filter_index(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.assertEqual(self.model._filter_index, [3, 4, 5]) def test_rowCount_when_filter(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.assertEqual(self.model.rowCount(), 3 + len(self.model._action_rows)) def test_add_to_selection_when_filter(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.assertFalse(self.model._add_to_selection) self.assertEqual( self.model.data(self.model.index(len(self.model._action_rows) - 1, 0), Qt.ItemDataRole.CheckStateRole), @@ -151,14 +149,14 @@ def test_add_to_selection_when_filter(self): def test_selected_when_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.assertEqual(self.model._selected, set(self.data)) self.assertEqual(self.model._selected_filtered, set(self.data[3:])) def test_get_data_when_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') - self.assertEqual(self.model.data(self.model.index(len(self.model._action_rows), 0)), 'b') + self.model.set_filter("b") + self.assertEqual(self.model.data(self.model.index(len(self.model._action_rows), 0)), "b") def test_data_works_when_show_empty_is_unset(self): self.model = SimpleFilterCheckboxListModel(None, show_empty=False) @@ -173,7 +171,7 @@ def test_data_works_when_show_empty_is_unset(self): def test_click_select_all_when_all_selected_and_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(0, 0) self.model._handle_index_clicked(index) @@ -183,7 +181,7 @@ def test_click_select_all_when_all_selected_and_filtered(self): def test_click_select_all_when_all_not_selected_and_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(2, 0) self.model._handle_index_clicked(index) @@ -195,7 +193,7 @@ def test_click_select_all_when_all_not_selected_and_filtered(self): def test_click_selected_item_when_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(len(self.model._action_rows), 0) self.model._handle_index_clicked(index) @@ -204,8 +202,8 @@ def test_click_selected_item_when_filtered(self): def test_click_unselected_item_when_filtered(self): self.model.set_list(self.data) - self.model.set_filter('b') - self.model._selected_filtered.discard('b') + self.model.set_filter("b") + self.model._selected_filtered.discard("b") with mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.dataChanged"): index = self.model.index(len(self.model._action_rows), 0) self.model._handle_index_clicked(index) @@ -214,7 +212,7 @@ def test_click_unselected_item_when_filtered(self): def test_remove_filter(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.model.remove_filter() self.assertFalse(self.model._is_filtered) self.assertEqual(self.model._selected, set(self.data)) @@ -222,7 +220,7 @@ def test_remove_filter(self): def test_apply_filter_with_replace(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.model.apply_filter() self.assertFalse(self.model._is_filtered) self.assertEqual(self.model._selected, set(self.data[3:])) @@ -231,9 +229,9 @@ def test_apply_filter_with_replace(self): def test_apply_filter_with_add(self): self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") self.model._add_to_selection = True - self.model._selected_filtered.discard('bbb') + self.model._selected_filtered.discard("bbb") self.model.apply_filter() self.assertFalse(self.model._is_filtered) self.assertEqual(self.model._selected, set(self.data[:5])) @@ -241,9 +239,9 @@ def test_apply_filter_with_add(self): self.assertFalse(self.model._all_selected) def test_add_item_with_select_with_filter_last(self): - new_item = ['bbbb'] + new_item = ["bbbb"] self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginInsertRows" ), mock.patch( @@ -259,9 +257,9 @@ def test_add_item_with_select_with_filter_last(self): self.assertEqual(self.model.data(self.model.index(3 + len(self.model._action_rows), 0)), new_item[0]) def test_add_item_with_select_with_filter_first(self): - new_item = ['0b'] + new_item = ["0b"] self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginInsertRows" ), mock.patch( @@ -274,9 +272,9 @@ def test_add_item_with_select_with_filter_first(self): self.assertEqual(self.model.data(self.model.index(3 + len(self.model._action_rows), 0)), new_item[0]) def test_add_item_with_select_with_filter_middle(self): - new_item = ['b1'] + new_item = ["b1"] self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginInsertRows" ), mock.patch( @@ -289,7 +287,7 @@ def test_add_item_with_select_with_filter_middle(self): self.assertEqual(self.model.data(self.model.index(3 + len(self.model._action_rows), 0)), new_item[0]) def test_remove_items_data(self): - items = set('a') + items = set("a") self.model.set_list(self.data) with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" @@ -299,7 +297,7 @@ def test_remove_items_data(self): self.assertEqual(self.model._data_set, set(self.data[1:])) def test_remove_items_selected(self): - items = set('a') + items = set("a") self.model.set_list(self.data) with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" @@ -309,9 +307,9 @@ def test_remove_items_selected(self): self.assertTrue(self.model._all_selected) def test_remove_items_not_selected(self): - items = set('a') + items = set("a") self.model.set_list(self.data) - self.model._selected.discard('a') + self.model._selected.discard("a") self.model._all_selected = False with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" @@ -321,9 +319,9 @@ def test_remove_items_not_selected(self): self.assertTrue(self.model._all_selected) def test_remove_items_filtered_data(self): - items = set('b') + items = set("b") self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" ), mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.endResetModel"): @@ -332,9 +330,9 @@ def test_remove_items_filtered_data(self): self.assertEqual(self.model._selected_filtered, set(self.data[4:])) def test_remove_items_filtered_data_middle(self): - items = set('bb') + items = set("bb") self.model.set_list(self.data) - self.model.set_filter('b') + self.model.set_filter("b") with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" ), mock.patch("spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.endResetModel"): @@ -342,10 +340,10 @@ def test_remove_items_filtered_data_middle(self): self.assertEqual(self.model._filter_index, [3, 4]) def test_remove_items_filtered_data_not_selected(self): - items = set('b') + items = set("b") self.model.set_list(self.data) - self.model.set_filter('b') - self.model._selected_filtered.discard('a') + self.model.set_filter("b") + self.model._selected_filtered.discard("a") self.model._all_selected = False with mock.patch( "spinetoolbox.mvcmodels.filter_checkbox_list_model.SimpleFilterCheckboxListModel.beginResetModel" @@ -354,6 +352,22 @@ def test_remove_items_filtered_data_not_selected(self): self.assertEqual(self.model._selected_filtered, set(self.data[4:])) self.assertTrue(self.model._all_selected) + def test_half_finished_expression_does_not_raise_exception(self): + self.model.set_list(self.data) + self.model.set_filter("[") + self.assertEqual( + [self.model.index(row, 0).data() for row in range(self.model.rowCount())], + ["(Select all)", "(Empty)"] + self.data, + ) + + def test_only_whitespaces_in_filter_expression_does_not_filter(self): + self.model.set_list(self.data) + self.model.set_filter(" ") + self.assertEqual( + [self.model.index(row, 0).data() for row in range(self.model.rowCount())], + ["(Select all)", "(Empty)"] + self.data, + ) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_IndexedValueTableModel.py b/tests/mvcmodels/test_IndexedValueTableModel.py index dc41fafac..069221a65 100644 --- a/tests/mvcmodels/test_IndexedValueTableModel.py +++ b/tests/mvcmodels/test_IndexedValueTableModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the IndexedValueTableModel class. -""" - +"""Unit tests for the IndexedValueTableModel class.""" import unittest from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication @@ -82,5 +80,5 @@ def test_row_count(self): self.assertEqual(self._model.rowCount(), 4) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_MapModel.py b/tests/mvcmodels/test_MapModel.py index 06dfe4ca9..c1e75f83a 100644 --- a/tests/mvcmodels/test_MapModel.py +++ b/tests/mvcmodels/test_MapModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for MapModel class. -""" - +"""Unit tests for MapModel class.""" import unittest from PySide6.QtCore import QObject, Qt from PySide6.QtGui import QColor @@ -569,5 +567,5 @@ def test_insertColumns_to_map(self): self.assertEqual(model.index(1, 3).data(), "") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_MinimalTableModel.py b/tests/mvcmodels/test_MinimalTableModel.py index ac4079c4a..a50e89326 100644 --- a/tests/mvcmodels/test_MinimalTableModel.py +++ b/tests/mvcmodels/test_MinimalTableModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the MinimalTableModel class. -""" - +"""Unit tests for the MinimalTableModel class.""" import unittest from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication @@ -72,7 +70,7 @@ def test_columnCount(self): def test_headerData(self): """Test the headerData() method of MinimalTableModel.""" model = MinimalTableModel() - labels = ['a', 'b', 'c'] + labels = ["a", "b", "c"] model.set_horizontal_header_labels(labels) for index, label in enumerate(labels): self.assertEqual(model.headerData(index), label) @@ -81,29 +79,29 @@ def test_headerData(self): def test_set_horizontal_header_labels(self): """Test the set_horizontal_header_labels() method of MinimalTableModel.""" model = MinimalTableModel() - model.set_horizontal_header_labels(['a', 'b']) - self.assertEqual(model.horizontal_header_labels(), ['a', 'b']) + model.set_horizontal_header_labels(["a", "b"]) + self.assertEqual(model.horizontal_header_labels(), ["a", "b"]) def test_insert_horizontal_header_labels(self): """Test the insert_horizontal_header_labels() method of MinimalTableModel.""" model = MinimalTableModel() - model.insert_horizontal_header_labels(0, ['a', 'b']) - self.assertEqual(model.horizontal_header_labels(), ['a', 'b']) - model.insert_horizontal_header_labels(0, ['c']) - self.assertEqual(model.horizontal_header_labels(), ['c', 'a', 'b']) - model.insert_horizontal_header_labels(1, ['d']) - self.assertEqual(model.horizontal_header_labels(), ['c', 'd', 'a', 'b']) - model.insert_horizontal_header_labels(4, ['e']) - self.assertEqual(model.horizontal_header_labels(), ['c', 'd', 'a', 'b', 'e']) + model.insert_horizontal_header_labels(0, ["a", "b"]) + self.assertEqual(model.horizontal_header_labels(), ["a", "b"]) + model.insert_horizontal_header_labels(0, ["c"]) + self.assertEqual(model.horizontal_header_labels(), ["c", "a", "b"]) + model.insert_horizontal_header_labels(1, ["d"]) + self.assertEqual(model.horizontal_header_labels(), ["c", "d", "a", "b"]) + model.insert_horizontal_header_labels(4, ["e"]) + self.assertEqual(model.horizontal_header_labels(), ["c", "d", "a", "b", "e"]) def test_setHeaderData(self): """Test the setHeaderData() method of MinimalTableModel.""" model = MinimalTableModel() - model.set_horizontal_header_labels(['a']) - self.assertTrue(model.setHeaderData(0, Qt.Orientation.Horizontal, 'b')) - self.assertEqual(model.horizontal_header_labels(), ['b']) - self.assertFalse(model.setHeaderData(0, Qt.Orientation.Vertical, 'c')) - self.assertFalse(model.setHeaderData(0, Qt.Orientation.Horizontal, 'd', role=Qt.ItemDataRole.ToolTipRole)) + model.set_horizontal_header_labels(["a"]) + self.assertTrue(model.setHeaderData(0, Qt.Orientation.Horizontal, "b")) + self.assertEqual(model.horizontal_header_labels(), ["b"]) + self.assertFalse(model.setHeaderData(0, Qt.Orientation.Vertical, "c")) + self.assertFalse(model.setHeaderData(0, Qt.Orientation.Horizontal, "d", role=Qt.ItemDataRole.ToolTipRole)) self.assertIsNone(model.headerData(0, role=Qt.ItemDataRole.ToolTipRole)) def test_data(self): @@ -111,8 +109,8 @@ def test_data(self): model = MinimalTableModel() model.insertRows(0, 1) index = model.index(0, 0) - model.setData(index, 'a') - self.assertTrue(model.data(index), 'a') + model.setData(index, "a") + self.assertTrue(model.data(index), "a") def test_row_data(self): """Test the row_data() method of MinimalTableModel.""" @@ -120,7 +118,7 @@ def test_row_data(self): model.insertRows(0, 1) n_columns = 3 model.insertColumns(0, n_columns - 1) - data = ['a', 'b', 'c'] + data = ["a", "b", "c"] for column in range(n_columns): index = model.index(0, column) model.setData(index, data[column]) @@ -131,8 +129,8 @@ def test_setData(self): model = MinimalTableModel() model.insertRows(0, 1) index = model.index(0, 0) - self.assertTrue(model.setData(index, 'a')) - self.assertEqual(model.data(index), 'a') + self.assertTrue(model.setData(index, "a")) + self.assertEqual(model.data(index), "a") def test_batch_set_data(self): """Test the batch_set_data() method of MinimalTableModel.""" @@ -142,7 +140,7 @@ def test_batch_set_data(self): model.insertRows(0, n_rows) n_columns = 3 model.insertColumns(0, n_columns) - background = n_rows * n_columns * ['0xdeadbeef'] + background = n_rows * n_columns * ["0xdeadbeef"] indices = list() for row in range(n_rows): for column in range(n_columns): @@ -159,7 +157,7 @@ def _handle_data_changed(top_left, bottom_right, roles): for row in range(n_rows): for column in range(n_columns): index = model.index(row, column) - self.assertEqual(model.data(index), '0xdeadbeef') + self.assertEqual(model.data(index), "0xdeadbeef") def test_insertRows(self): """Test the insertRows() method of MinimalTableModel.""" @@ -178,17 +176,17 @@ def check_data(expecteds): self.assertIsNone(model.data(index)) index = model.index(0, 0) - model.setData(index, 'a') + model.setData(index, "a") index = model.index(1, 0) - model.setData(index, 'b') + model.setData(index, "b") self.assertTrue(model.insertRows(1, 1)) self.assertEqual(model.rowCount(), 3) - check_data(['a', None, 'b']) + check_data(["a", None, "b"]) index = model.index(1, 0) - model.setData(index, 'c') + model.setData(index, "c") self.assertTrue(model.insertRows(3, 1)) self.assertEqual(model.rowCount(), 4) - check_data(['a', 'c', 'b', None]) + check_data(["a", "c", "b", None]) def test_insertColumns(self): """Test the insertColumns() method of MinimalTableModel.""" @@ -196,7 +194,7 @@ def test_insertColumns(self): model.insertRows(0, 1) self.assertEqual(model.columnCount(), 1) index = model.index(0, 0) - model.setData(index, 'a') + model.setData(index, "a") self.assertTrue(model.insertColumns(0, 1)) self.assertEqual(model.columnCount(), 2) @@ -208,24 +206,24 @@ def check_data(expecteds): else: self.assertIsNone(model.data(index)) - check_data([None, 'a']) + check_data([None, "a"]) index = model.index(0, 0) - model.setData(index, 'b') + model.setData(index, "b") self.assertTrue(model.insertColumns(1, 1)) self.assertEqual(model.columnCount(), 3) - check_data(['b', None, 'a']) + check_data(["b", None, "a"]) index = model.index(0, 1) - model.setData(index, 'c') + model.setData(index, "c") self.assertTrue(model.insertColumns(3, 1)) self.assertEqual(model.columnCount(), 4) - check_data(['b', 'c', 'a', None]) + check_data(["b", "c", "a", None]) def test_removeRows(self): """Test the removeRows() method of MinimalTableModel.""" model = MinimalTableModel() self.assertFalse(model.removeRows(-1, 1)) self.assertFalse(model.removeRows(0, 1)) - data = ['a', 'b', 'c', 'd', 'e'] + data = ["a", "b", "c", "d", "e"] model.insertRows(0, len(data)) for row, value in enumerate(data): index = model.index(row, 0) @@ -238,13 +236,13 @@ def check_data(expecteds): index = model.index(row, 0) self.assertEqual(model.data(index), expected) - check_data(['a', 'd', 'e']) + check_data(["a", "d", "e"]) self.assertTrue(model.removeRows(2, 1)) self.assertEqual(model.rowCount(), 2) - check_data(['a', 'd']) + check_data(["a", "d"]) self.assertTrue(model.removeRows(0, 1)) self.assertEqual(model.rowCount(), 1) - check_data(['d']) + check_data(["d"]) self.assertTrue(model.removeRows(0, 1)) self.assertEqual(model.rowCount(), 0) @@ -255,7 +253,7 @@ def test_removeColumns(self): self.assertFalse(model.removeColumns(0, 1)) model.insertRows(0, 1) model.insertColumns(0, 4) - data = ['a', 'b', 'c', 'd', 'e'] + data = ["a", "b", "c", "d", "e"] for column, value in enumerate(data): index = model.index(0, column) model.setData(index, value) @@ -267,18 +265,18 @@ def check_data(expecteds): index = model.index(0, column) self.assertEqual(model.data(index), expected) - check_data(['a', 'b', 'c', 'd']) + check_data(["a", "b", "c", "d"]) self.assertTrue(model.removeColumns(0, 1)) self.assertEqual(model.columnCount(), 3) - check_data(['b', 'c', 'd']) + check_data(["b", "c", "d"]) self.assertTrue(model.removeColumns(1, 1)) self.assertEqual(model.columnCount(), 2) - check_data(['b', 'd']) + check_data(["b", "d"]) def test_reset_model(self): """Test the reset_model() method of MinimalTableModel.""" model = MinimalTableModel() - data = [['a', 'b', 'c'], ['d', 'e', 'f']] + data = [["a", "b", "c"], ["d", "e", "f"]] model.reset_model(data) for row, row_data in enumerate(data): for column, value in enumerate(row_data): @@ -286,5 +284,5 @@ def test_reset_model(self): self.assertEqual(model.data(index), value) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_ProjectItemModel.py b/tests/mvcmodels/test_ProjectItemModel.py deleted file mode 100644 index 3a1b20136..000000000 --- a/tests/mvcmodels/test_ProjectItemModel.py +++ /dev/null @@ -1,94 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Unit tests for ProjectItemModel class. -""" - -from tempfile import TemporaryDirectory -import unittest -from unittest.mock import NonCallableMagicMock -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication -from spinetoolbox.mvcmodels.project_item_model import ProjectItemModel -from spinetoolbox.mvcmodels.project_tree_item import CategoryProjectTreeItem, LeafProjectTreeItem, RootProjectTreeItem -from spinetoolbox.project_item.project_item import ProjectItem -from ..mock_helpers import clean_up_toolbox, create_toolboxui_with_project - - -class TestProjectItemModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - """Sets up toolbox.""" - self._temp_dir = TemporaryDirectory() - self.toolbox = create_toolboxui_with_project(self._temp_dir.name) - - def tearDown(self): - """Cleans up.""" - clean_up_toolbox(self.toolbox) - self._temp_dir.cleanup() - - def test_empty_model(self): - root = RootProjectTreeItem() - model = ProjectItemModel(root) - self.assertEqual(model.rowCount(), 0) - self.assertEqual(model.columnCount(), 1) - self.assertEqual(model.n_items(), 0) - self.assertFalse(model.items()) - - def test_insert_item_category_item(self): - root = RootProjectTreeItem() - model = ProjectItemModel(root) - category = CategoryProjectTreeItem("category", "category description") - model.insert_item(category) - self.assertEqual(model.rowCount(), 1) - self.assertEqual(model.n_items(), 0) - category_index = model.find_category("category") - self.assertTrue(category_index.isValid()) - self.assertEqual(category_index.row(), 0) - self.assertEqual(category_index.column(), 0) - self.assertEqual(model.data(category_index, Qt.ItemDataRole.DisplayRole), "category") - - def test_insert_item_leaf_item(self): - root = RootProjectTreeItem() - model = ProjectItemModel(root) - category = CategoryProjectTreeItem("category", "category description") - model.insert_item(category) - category_index = model.find_category("category") - mock_project_item = NonCallableMagicMock() - mock_project_item.name = "project item" - mock_project_item.description = "project item description" - leaf = LeafProjectTreeItem(mock_project_item) - model.insert_item(leaf, category_index) - self.assertEqual(model.rowCount(), 1) - self.assertEqual(model.rowCount(category_index), 1) - self.assertEqual(model.n_items(), 1) - self.assertEqual(model.items("category"), [leaf]) - - def test_category_of_item(self): - root = RootProjectTreeItem() - category = CategoryProjectTreeItem("category", "category description") - root.add_child(category) - model = ProjectItemModel(root) - self.assertEqual(model.category_of_item("nonexistent item"), None) - project_item = ProjectItem("item", "item description", 0.0, 0.0, self.toolbox.project()) - item = LeafProjectTreeItem(project_item) - category.add_child(item) - found_category = model.category_of_item("item") - self.assertEqual(found_category.name, category.name) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/mvcmodels/test_TimePatternModel.py b/tests/mvcmodels/test_TimePatternModel.py index 586caaf40..1fbdfb995 100644 --- a/tests/mvcmodels/test_TimePatternModel.py +++ b/tests/mvcmodels/test_TimePatternModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimePatternModel class. -""" - +"""Unit tests for the TimePatternModel class.""" import unittest import numpy as np import numpy.testing @@ -37,91 +35,91 @@ def test_flags(self): model.deleteLater() def test_insert_rows_in_the_beginning(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.insertRows(0, 1)) self.assertEqual(len(model.value), 3) - self.assertEqual(model.value.indexes, ['', 'M7-12', 'M1-6']) + self.assertEqual(model.value.indexes, ["", "M7-12", "M1-6"]) numpy.testing.assert_equal(model.value.values, np.array([0.0, -5.0, 7.0])) model.deleteLater() def test_insert_single_row_in_the_middle(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.insertRows(1, 1)) self.assertEqual(len(model.value), 3) - self.assertEqual(model.value.indexes, ['M7-12', '', 'M1-6']) + self.assertEqual(model.value.indexes, ["M7-12", "", "M1-6"]) numpy.testing.assert_equal(model.value.values, np.array([-5.0, 0.0, 7.0])) model.deleteLater() def test_insert_multiple_rows_in_the_middle(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.insertRows(1, 3)) self.assertEqual(len(model.value), 5) - self.assertEqual(model.value.indexes, ['M7-12', '', '', '', 'M1-6']) + self.assertEqual(model.value.indexes, ["M7-12", "", "", "", "M1-6"]) numpy.testing.assert_equal(model.value.values, np.array([-5.0, 0.0, 0.0, 0.0, 7.0])) model.deleteLater() def test_insert_rows_in_the_end(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.insertRows(2, 1)) self.assertEqual(len(model.value), 3) - self.assertEqual(model.value.indexes, ['M7-12', 'M1-6', '']) + self.assertEqual(model.value.indexes, ["M7-12", "M1-6", ""]) numpy.testing.assert_equal(model.value.values, np.array([-5.0, 7.0, 0.0])) model.deleteLater() def test_remove_rows_from_the_beginning(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.removeRows(0, 1)) self.assertEqual(len(model.value), 1) - self.assertEqual(model.value.indexes, ['M1-6']) + self.assertEqual(model.value.indexes, ["M1-6"]) numpy.testing.assert_equal(model.value.values, np.array([7.0])) model.deleteLater() def test_remove_rows_from_the_middle(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6', 'M4-9'], [-5.0, 3.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6", "M4-9"], [-5.0, 3.0, 7.0]), None) self.assertTrue(model.removeRows(1, 1)) self.assertEqual(len(model.value), 2) - self.assertEqual(model.value.indexes, ['M7-12', 'M4-9']) + self.assertEqual(model.value.indexes, ["M7-12", "M4-9"]) numpy.testing.assert_equal(model.value.values, np.array([-5.0, 7.0])) model.deleteLater() def test_remove_rows_from_the_end(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.removeRows(1, 1)) self.assertEqual(len(model.value), 1) - self.assertEqual(model.value.indexes, ['M7-12']) + self.assertEqual(model.value.indexes, ["M7-12"]) numpy.testing.assert_equal(model.value.values, [-5.0]) model.deleteLater() def test_cannot_remove_all_rows(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) self.assertTrue(model.removeRows(0, 2)) self.assertEqual(len(model.value), 1) - self.assertEqual(model.value.indexes, ['M7-12']) + self.assertEqual(model.value.indexes, ["M7-12"]) numpy.testing.assert_equal(model.value.values, [-5.0]) model.deleteLater() def test_removing_last_row_fails(self): - model = TimePatternModel(TimePattern(['M7-12'], [-5.0]), None) + model = TimePatternModel(TimePattern(["M7-12"], [-5.0]), None) self.assertFalse(model.removeRows(0, 1)) model.deleteLater() def test_setData(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6'], [-5.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6"], [-5.0, 7.0]), None) model_index = model.index(1, 1) model.setData(model_index, 2.3) - self.assertEqual(model.value.indexes, ['M7-12', 'M1-6']) + self.assertEqual(model.value.indexes, ["M7-12", "M1-6"]) numpy.testing.assert_equal(model.value.values, [-5.0, 2.3]) model.deleteLater() def test_batch_set_data(self): - model = TimePatternModel(TimePattern(['M7-12', 'M1-6', 'M4-9'], [-5.0, 3.0, 7.0]), None) + model = TimePatternModel(TimePattern(["M7-12", "M1-6", "M4-9"], [-5.0, 3.0, 7.0]), None) indexes = [model.index(0, 0), model.index(1, 1), model.index(2, 1)] - values = ['D1-7', 55.5, -55.5] + values = ["D1-7", 55.5, -55.5] model.batch_set_data(indexes, values) - expected = TimePattern(['D1-7', 'M1-6', 'M4-9'], [-5.0, 55.5, -55.5]) + expected = TimePattern(["D1-7", "M1-6", "M4-9"], [-5.0, 55.5, -55.5]) self.assertEqual(model.value, expected) model.deleteLater() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_TimeSeriesModelFixedResolution.py b/tests/mvcmodels/test_TimeSeriesModelFixedResolution.py index ba487fa96..cb938c1ce 100644 --- a/tests/mvcmodels/test_TimeSeriesModelFixedResolution.py +++ b/tests/mvcmodels/test_TimeSeriesModelFixedResolution.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimeSeriesModelFixedResolution class. -""" - +"""Unit tests for the TimeSeriesModelFixedResolution class.""" import unittest import dateutil.parser from dateutil.relativedelta import relativedelta @@ -56,7 +54,7 @@ def test_indexes(self): # pylint: disable=no-self-use model = TimeSeriesModelFixedResolution( TimeSeriesFixedResolution("2019-07-05T12:00", "2 hours", [-5.0, 7.0], True, False), None ) - self.assertEqual(model.indexes, numpy.array(["2019-07-05T12:00", "2019-07-05T14:00"], dtype='datetime64')) + self.assertEqual(model.indexes, numpy.array(["2019-07-05T12:00", "2019-07-05T14:00"], dtype="datetime64")) model.deleteLater() def test_insertRows_at_the_beginning(self): @@ -228,5 +226,5 @@ def test_batch_set_data(self): model.deleteLater() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_TimeSeriesModelVariableResolution.py b/tests/mvcmodels/test_TimeSeriesModelVariableResolution.py index 37e84f973..fa0ade13c 100644 --- a/tests/mvcmodels/test_TimeSeriesModelVariableResolution.py +++ b/tests/mvcmodels/test_TimeSeriesModelVariableResolution.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimeSeriesModelVariableResolution class. -""" - +"""Unit tests for the TimeSeriesModelVariableResolution class.""" import unittest import numpy from PySide6.QtCore import QObject, Qt @@ -249,5 +247,5 @@ def test_batch_set_data(self): self.assertEqual(model.value, expected) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/mvcmodels/test_project_tree_item.py b/tests/mvcmodels/test_project_tree_item.py deleted file mode 100644 index 15a9074ec..000000000 --- a/tests/mvcmodels/test_project_tree_item.py +++ /dev/null @@ -1,112 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Unit tests for project_tree_item module. -""" - -from tempfile import TemporaryDirectory -import unittest -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication -from spinetoolbox.project_item.project_item import ProjectItem -from spinetoolbox.mvcmodels.project_tree_item import ( - BaseProjectTreeItem, - CategoryProjectTreeItem, - LeafProjectTreeItem, - RootProjectTreeItem, -) -from ..mock_helpers import clean_up_toolbox, create_toolboxui_with_project - - -class TestLeafProjectTreeItem(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def test_BaseProjectTreeItem_initial_state(self): - item = BaseProjectTreeItem("items name", "description") - self.assertEqual(item.name, "items name") - self.assertEqual(item.description, "description") - self.assertEqual(item.short_name, "items_name") - self.assertIsNone(item.parent()) - self.assertEqual(item.child_count(), 0) - self.assertFalse(item.children()) - - def test_BaseProjectTreeItem_flags(self): - item = BaseProjectTreeItem("name", "description") - self.assertEqual(item.flags(), Qt.NoItemFlags) - - def test_CategoryProjectTreeItem_flags(self): - with TemporaryDirectory() as project_dir: - toolbox, item = self._category_item(project_dir) - self.assertEqual(item.flags(), Qt.ItemIsEnabled) - clean_up_toolbox(toolbox) - - def test_LeafProjectTreeItem_flags(self): - with TemporaryDirectory() as project_dir: - toolbox = create_toolboxui_with_project(project_dir) - item = self._leaf_item(toolbox) - self.assertEqual(item.flags(), Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) - clean_up_toolbox(toolbox) - - def test_RootProjectTreeItem_initial_name_and_description(self): - item = RootProjectTreeItem() - self.assertEqual(item.name, "root") - self.assertEqual(item.description, "The Root Project Tree Item.") - - def test_RootProjectTreeItem_parent_child_hierarchy(self): - parent = RootProjectTreeItem() - with TemporaryDirectory() as project_dir: - toolbox, child = self._category_item(project_dir) - parent.add_child(child) - self.assertEqual(parent.child_count(), 1) - self.assertEqual(parent.children()[0], child) - self.assertEqual(child.parent(), parent) - self.assertEqual(child.row(), 0) - parent.remove_child(0) - self.assertEqual(parent.child_count(), 0) - self.assertFalse(parent.children()) - self.assertIsNone(child.parent()) - clean_up_toolbox(toolbox) - - def test_CategoryProjectTreeItem_parent_child_hierarchy(self): - with TemporaryDirectory() as project_dir: - toolbox, parent = self._category_item(project_dir) - leaf = self._leaf_item(toolbox) - parent.add_child(leaf) - self.assertEqual(parent.child_count(), 1) - self.assertEqual(parent.children()[0], leaf) - self.assertEqual(leaf.parent(), parent) - self.assertEqual(leaf.row(), 0) - parent.remove_child(0) - self.assertEqual(parent.child_count(), 0) - self.assertFalse(parent.children()) - self.assertIsNone(leaf.parent()) - clean_up_toolbox(toolbox) - - @staticmethod - def _category_item(project_dir): - """Set up toolbox.""" - toolbox = create_toolboxui_with_project(project_dir) - item = CategoryProjectTreeItem("category item", "A category tree item") - return toolbox, item - - @staticmethod - def _leaf_item(toolbox): - project_item = ProjectItem("PI", "A Project item", 0.0, 0.0, toolbox.project()) - item = LeafProjectTreeItem(project_item) - return item - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/mvcmodels/test_resource_filter_model.py b/tests/mvcmodels/test_resource_filter_model.py index d971238e3..1cae8fb47 100644 --- a/tests/mvcmodels/test_resource_filter_model.py +++ b/tests/mvcmodels/test_resource_filter_model.py @@ -13,14 +13,12 @@ import unittest from unittest import mock from contextlib import contextmanager - from PySide6.QtCore import QObject, Qt from PySide6.QtGui import QUndoStack from PySide6.QtWidgets import QApplication - from spine_engine.project_item.project_item_resource import database_resource +from spinedb_api.filters.alternative_filter import ALTERNATIVE_FILTER_TYPE from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE -from spinedb_api.filters.tool_filter import TOOL_FILTER_TYPE from spinetoolbox.mvcmodels.resource_filter_model import ResourceFilterModel @@ -45,11 +43,13 @@ def test_setData_changes_checked_state(self): project.find_connection.return_value = connection def online_filters(resource_label, resource_type): - return {SCENARIO_FILTER_TYPE: {}, TOOL_FILTER_TYPE: {}}[resource_type] + return {SCENARIO_FILTER_TYPE: {"my_scenario": True}, ALTERNATIVE_FILTER_TYPE: {}}[resource_type] connection.online_filters.side_effect = online_filters - connection.get_scenario_names.return_value = ["my_scenario"] - connection.get_tool_names.return_value = ["my_tool"] + connection.get_filter_item_names.side_effect = lambda filter_type, url: { + SCENARIO_FILTER_TYPE: ["my_scenario"], + ALTERNATIVE_FILTER_TYPE: ["Base"], + }[filter_type] connection.is_filter_online_by_default = True with resource_filter_model(connection, project, self._undo_stack, self._logger) as model: connection.resource_filter_model = model @@ -71,15 +71,25 @@ def online_filters(resource_label, resource_type): model.setData(my_scenario_index, Qt.CheckState.Checked.value, Qt.ItemDataRole.CheckStateRole) ) self.assertEqual(model.data(my_scenario_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Checked.value) - tool_root_index = model.index(1, 0, root_index) - self.assertEqual(model.rowCount(tool_root_index), 2) - my_tool_index = model.index(1, 0, tool_root_index) - self.assertEqual(my_tool_index.data(), "my_tool") - self.assertEqual(model.data(my_tool_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Checked.value) - self.assertTrue(model.setData(my_tool_index, Qt.CheckState.Unchecked.value, Qt.ItemDataRole.CheckStateRole)) - self.assertEqual(model.data(my_tool_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Unchecked.value) - self.assertTrue(model.setData(my_tool_index, Qt.CheckState.Checked.value, Qt.ItemDataRole.CheckStateRole)) - self.assertEqual(model.data(my_tool_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Checked.value) + alternative_root_index = model.index(1, 0, root_index) + self.assertEqual(model.rowCount(alternative_root_index), 2) + base_alternative_index = model.index(1, 0, alternative_root_index) + self.assertEqual(base_alternative_index.data(), "Base") + self.assertEqual( + model.data(base_alternative_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Checked.value + ) + self.assertTrue( + model.setData(base_alternative_index, Qt.CheckState.Unchecked.value, Qt.ItemDataRole.CheckStateRole) + ) + self.assertEqual( + model.data(base_alternative_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Unchecked.value + ) + self.assertTrue( + model.setData(base_alternative_index, Qt.CheckState.Checked.value, Qt.ItemDataRole.CheckStateRole) + ) + self.assertEqual( + model.data(base_alternative_index, Qt.ItemDataRole.CheckStateRole), Qt.CheckState.Checked.value + ) @contextmanager @@ -91,5 +101,5 @@ def resource_filter_model(connection, project, undo_stack, logger): model.deleteLater() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/project_item/__init__.py b/tests/project_item/__init__.py index c8f6ba8b7..3da3b0915 100644 --- a/tests/project_item/__init__.py +++ b/tests/project_item/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Init file for project_item test package. Intentionally empty. -""" +"""Init file for project_item test package. Intentionally empty.""" diff --git a/tests/project_item/test_ProjectItem.py b/tests/project_item/test_ProjectItem.py index e3ef80ef2..b5044650b 100644 --- a/tests/project_item/test_ProjectItem.py +++ b/tests/project_item/test_ProjectItem.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ProjectItem base class. -""" - +"""Unit tests for ProjectItem base class.""" from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock, NonCallableMagicMock diff --git a/tests/project_item/test_logging_connection.py b/tests/project_item/test_logging_connection.py index ca9dfe711..260f0c6af 100644 --- a/tests/project_item/test_logging_connection.py +++ b/tests/project_item/test_logging_connection.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,15 +9,14 @@ # 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 . ###################################################################################################################### + """Unit tests for the ``logging_connection`` module.""" from pathlib import Path from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock - from PySide6.QtGui import QColor from PySide6.QtWidgets import QApplication - from spine_engine.project_item.project_item_resource import database_resource from spine_engine.project_item.connection import FilterSettings from spinedb_api.filters.scenario_filter import SCENARIO_FILTER_TYPE @@ -181,10 +181,6 @@ def __init__(self, name, project): def item_type(): return "Mock Data Store" - @staticmethod - def item_category(): - return "Data Stores" - # pylint: disable=no-self-use def resources_for_direct_successors(self): return [ @@ -203,5 +199,5 @@ def __init__(self, toolbox): self.ui = object() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/project_item/test_specification_editor_window.py b/tests/project_item/test_specification_editor_window.py index 9ddfb1d08..b493a4283 100644 --- a/tests/project_item/test_specification_editor_window.py +++ b/tests/project_item/test_specification_editor_window.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,13 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for :class:`SpecificationEditorWindowBase` and its supports. -""" +"""Unit tests for :class:`SpecificationEditorWindowBase` and its supports.""" from tempfile import TemporaryDirectory import unittest from unittest.mock import call, MagicMock, patch, PropertyMock -from PySide6.QtGui import QColor, QUndoStack +from PySide6.QtGui import QColor, QUndoStack, QIcon from PySide6.QtWidgets import QApplication from spine_engine.project_item.project_item_specification import ProjectItemSpecification from spinetoolbox.project_item.project_item import ProjectItem @@ -25,6 +24,7 @@ ChangeSpecPropertyCommand, SpecificationEditorWindowBase, ) +from spinetoolbox.widgets.toolbars import ToolBar from tests.mock_helpers import clean_up_toolbox, create_toolboxui_with_project @@ -99,16 +99,28 @@ def test_save_specification(self): SpecificationEditorWindowBase, "_make_new_specification" ) as mock_make_specification, patch.object( ProjectItemSpecification, "save" - ) as mock_save: + ) as mock_save, patch.object( + ProjectItemFactory, "icon" + ) as mock_icon, patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: + specification = ProjectItemSpecification("spec name", "spec description", "Mock") mock_settings_group.return_value = "settings group" - specification = ProjectItemSpecification("spec name", "spec description") mock_make_specification.return_value = specification mock_save.return_value = {} + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + self._toolbox.item_factories = {"Mock": ProjectItemFactory()} window = SpecificationEditorWindowBase(self._toolbox) - window._spec_toolbar._line_edit_name.setText("spec name") - window._spec_toolbar._line_edit_name.editingFinished.emit() + name_edit = window._spec_toolbar._line_edit_name + name_edit.setText("spec name") + name_edit.textEdited.emit(name_edit.text()) window._spec_toolbar.save_action.trigger() + mock_settings_group.assert_called() + mock_make_specification.assert_called() mock_save.assert_called_once() + mock_icon.assert_called() + mock_icon_color.assert_called() window.deleteLater() def test_make_new_specification_for_item(self): @@ -118,19 +130,39 @@ def test_make_new_specification_for_item(self): SpecificationEditorWindowBase, "_make_new_specification" ) as mock_make_specification, patch.object( ProjectItemSpecification, "save" - ) as mock_save: + ) as mock_save, patch.object( + ProjectItemFactory, "make_icon" + ) as mock_make_icon, patch.object( + ProjectItemFactory, "icon" + ) as mock_icon, patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: mock_settings_group.return_value = "settings group" + mock_make_icon.return_value = ProjectItemIcon( + self._toolbox, ":/icons/item_icons/hammer.svg", QColor("white") + ) specification = ProjectItemSpecification("spec name", "spec description", "Mock") mock_make_specification.return_value = specification mock_save.return_value = {} + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + self._toolbox.item_factories = {"Mock": ProjectItemFactory()} + self._toolbox._item_properties_uis = {"Mock": MagicMock()} project_item = _MockProjectItem("item name", "item description", 0.0, 0.0, self._toolbox.project()) project_item._toolbox = self._toolbox + self._toolbox.project().add_item(project_item) window = SpecificationEditorWindowBase(self._toolbox, item=project_item) self.assertIs(window.item, project_item) - window._spec_toolbar._line_edit_name.setText("spec name") - window._spec_toolbar._line_edit_name.editingFinished.emit() + name_edit = window._spec_toolbar._line_edit_name + name_edit.setText("spec name") + name_edit.textEdited.emit(name_edit.text()) window._spec_toolbar.save_action.trigger() self.assertIs(project_item.specification(), specification) + mock_settings_group.assert_called() + mock_make_specification.assert_called() + mock_save.assert_called() + mock_icon.assert_called() + mock_icon_color.assert_called() window.deleteLater() def test_rename_specification_for_item(self): @@ -142,28 +174,41 @@ def test_rename_specification_for_item(self): ProjectItemSpecification, "save" ) as mock_save, patch.object( ProjectItemFactory, "make_icon" - ) as mock_make_icon: + ) as mock_make_icon, patch.object( + ProjectItemFactory, "icon" + ) as mock_icon, patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: mock_settings_group.return_value = "settings group" mock_make_icon.return_value = ProjectItemIcon( self._toolbox, ":/icons/item_icons/hammer.svg", QColor("white") ) - self._toolbox.item_factories["Mock"] = ProjectItemFactory() - self._toolbox._item_properties_uis["Mock"] = MagicMock() + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + self._toolbox.item_factories = {"Mock": ProjectItemFactory()} + self._toolbox._item_properties_uis = {"Mock": MagicMock()} specification = ProjectItemSpecification("spec name", "spec description", "Mock") project_item = _MockProjectItem("item name", "item description", 0.0, 0.0, self._toolbox.project()) project_item._toolbox = self._toolbox - project_item.set_specification(specification) self._toolbox.project().add_item(project_item) + project_item.set_specification(specification) window = SpecificationEditorWindowBase(self._toolbox, item=project_item) - mock_make_specification.side_effect = lambda name, exiting: ProjectItemSpecification( + mock_make_specification.side_effect = lambda name: ProjectItemSpecification( name, window._spec_toolbar.description(), "Mock" ) mock_save.return_value = {} - window._spec_toolbar._line_edit_name.setText("new spec name") - window._spec_toolbar._line_edit_name.editingFinished.emit() + name_edit = window._spec_toolbar._line_edit_name + name_edit.setText("new spec name") + name_edit.textEdited.emit(name_edit.text()) window._spec_toolbar.save_action.trigger() item_specification = project_item.specification() self.assertEqual(item_specification.name, "new spec name") + mock_settings_group.assert_called() + mock_make_specification.assert_called() + mock_save.assert_called() + mock_make_icon.assert_called() + mock_icon.assert_called() + mock_icon_color.assert_called() window.deleteLater() @@ -174,9 +219,8 @@ class _MockProjectItem(ProjectItem): def item_type(): return "Mock" - @staticmethod - def item_category(): - return "Tools" + def get_icon(self): + return ProjectItemIcon(self._toolbox, ":/icons/item_icons/hammer.svg", QColor("white")) if __name__ == "__main__": diff --git a/tests/server/__init__.py b/tests/server/__init__.py index 4868b91e0..72ab348d4 100644 --- a/tests/server/__init__.py +++ b/tests/server/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Toolbox. # Spine Engine 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Package for Remote Spine Engine Client side tests. -""" +"""Package for Remote Spine Engine Client side tests.""" diff --git a/tests/server/test_EngineClient.py b/tests/server/test_EngineClient.py index c643ede73..210cb0b85 100644 --- a/tests/server/test_EngineClient.py +++ b/tests/server/test_EngineClient.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Toolbox. # Spine Engine 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) @@ -9,13 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains tests for the EngineClient class. -""" - - +"""Contains tests for the EngineClient class.""" import unittest -import json import os from unittest import mock from tempfile import TemporaryDirectory @@ -26,9 +22,7 @@ from spine_engine.server.engine_server import EngineServer, ServerSecurityModel from spine_engine.execution_managers.persistent_execution_manager import PythonPersistentExecutionManager from spine_engine.exception import RemoteEngineInitFailed -from spine_engine.server.util.event_data_converter import EventDataConverter -from spine_items.tool.tool_specifications import PythonTool -from tests.mock_helpers import create_toolboxui_with_project, clean_up_toolbox, add_dc, add_tool +from tests.mock_helpers import create_toolboxui_with_project, clean_up_toolbox client_sec_dir = os.path.join(str(Path(__file__).parent), "client_secfolder") @@ -176,65 +170,6 @@ def test_remove_project_from_server(self): client.remove_project_from_server(job_id) client.close() - # def test_start_execution(self): - # project_zip_fpath = os.path.join(str(Path(__file__).parent), "helloworld.zip") - # client = EngineClient("localhost", 5601, ClientSecurityModel.NONE, "") - # job_id = client.upload_project("Hello World", project_zip_fpath) - # start_execution_response = client.start_execution("", job_id) - - # def test_engine_client_execution(self): - # """Tests EngineClient part when executing a DC->Tool DAG on a remote server.""" - # engine_data = self.make_engine_data_for_helloworld_project() - # msg_data_json = json.dumps(engine_data) - # zip_fname = "helloworld.zip" - # zip_fpath = os.path.join(str(Path(__file__).parent), zip_fname) - # client = EngineClient(self.host, self.port, ClientSecurityModel.NONE, "") - # start_event = client.send(msg_data_json, zip_fpath) - # self.assertEqual("remote_execution_started", start_event[0]) - # client.connect_sub_socket(start_event[1]) - # while True: - # rcv = client.sub_socket.recv_multipart() - # event = EventDataConverter.deconvert(rcv[1]) - # if event[0] == "dag_exec_finished": - # if event[1] != "COMPLETED": - # self.fail() - # break - # client.close() - - # def make_engine_data_for_helloworld_project(self): - # """Returns an engine data dictionary for SpineEngine() for the project in file helloworld.zip. - # - # engine_data dict must be the same as what is passed to SpineEngineWorker() in - # spinetoolbox.project.create_engine_worker() - # """ - # specification = PythonTool(name="helloworld2", tooltype="python", path="../../..", - # includes=["helloworld.py"], inputfiles=["input2.txt"], - # execute_in_work=True, settings=self.toolbox.qsettings(), logger=mock.Mock()) - # self.toolbox.project().add_specification(specification, save_to_disk=False) - # add_tool(self.toolbox.project(), self.toolbox.item_factories, "helloworld", tool_spec="helloworld2") - # add_dc(self.toolbox.project(), self.toolbox.item_factories, "Data Connection 1", - # file_refs=[{"type": "path", "relative": True, "path": "input2.txt"}]) - # tool_item_dict = self.toolbox.project().get_item("helloworld").item_dict() - # dc_item_dict = self.toolbox.project().get_item("Data Connection 1").item_dict() - # spec_dict = specification.to_dict() - # spec_dict["definition_file_path"] = "./helloworld/.spinetoolbox/specifications/Tool/helloworld2.json" - # item_dicts = dict() - # item_dicts["helloworld"] = tool_item_dict - # item_dicts["Data Connection 1"] = dc_item_dict - # specification_dicts = dict() - # specification_dicts["Tool"] = [spec_dict] - # engine_data = { - # "items": item_dicts, - # "specifications": specification_dicts, - # "connections": [{"from": ["Data Connection 1", "left"], "to": ["helloworld", "right"]}], - # "jumps": [], - # "execution_permits": {"Data Connection 1": True, "helloworld": True}, - # "items_module_name": "spine_items", - # "settings": {}, - # "project_dir": "./helloworld", - # } - # return engine_data - if __name__ == "__main__": unittest.main() diff --git a/tests/server/test_RemoteSpineEngineManager.py b/tests/server/test_RemoteSpineEngineManager.py index 174a5e3cd..7c7724359 100644 --- a/tests/server/test_RemoteSpineEngineManager.py +++ b/tests/server/test_RemoteSpineEngineManager.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # This file is part of Spine Toolbox. # Spine Engine 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) @@ -9,15 +10,13 @@ # this program. If not, see . ###################################################################################################################### -""" -Tests for Remote Spine Engine Manager. -""" - +"""Tests for Remote Spine Engine Manager.""" import unittest from unittest import mock from spinetoolbox.spine_engine_manager import RemoteSpineEngineManager from spine_engine import ItemExecutionFinishState from spine_engine.server.util.event_data_converter import EventDataConverter +from spine_engine.utils.helpers import ExecutionDirection class TestRemoteSpineEngineManager(unittest.TestCase): @@ -64,36 +63,36 @@ def yield_events_dag_succeeds(): # Convert some events fresh from SpineEngine first into # (bytes) json strings to simulate events that arrive to EngineClient engine_events = [ - ('exec_started', {'item_name': 'Data Connection 1', 'direction': 'BACKWARD'}), + ("exec_started", {"item_name": "Data Connection 1", "direction": ExecutionDirection.BACKWARD}), ( - 'exec_finished', + "exec_finished", { - 'item_name': 'Data Connection 1', - 'direction': 'BACKWARD', - 'state': 'RUNNING', - 'item_state': ItemExecutionFinishState.SUCCESS, + "item_name": "Data Connection 1", + "direction": ExecutionDirection.BACKWARD, + "state": "RUNNING", + "item_state": ItemExecutionFinishState.SUCCESS, }, ), - ('exec_started', {'item_name': 'Data Connection 1', 'direction': 'FORWARD'}), + ("exec_started", {"item_name": "Data Connection 1", "direction": ExecutionDirection.FORWARD}), ( - 'event_msg', + "event_msg", { - 'item_name': 'Data Connection 1', - 'filter_id': '', - 'msg_type': 'msg_success', - 'msg_text': 'Executing Data Connection Data Connection 1 finished', + "item_name": "Data Connection 1", + "filter_id": "", + "msg_type": "msg_success", + "msg_text": "Executing Data Connection Data Connection 1 finished", }, ), ( - 'exec_finished', + "exec_finished", { - 'item_name': 'Data Connection 1', - 'direction': 'FORWARD', - 'state': 'RUNNING', - 'item_state': ItemExecutionFinishState.SUCCESS, + "item_name": "Data Connection 1", + "direction": ExecutionDirection.FORWARD, + "state": "RUNNING", + "item_state": ItemExecutionFinishState.SUCCESS, }, ), - ('dag_exec_finished', 'COMPLETED'), + ("dag_exec_finished", "COMPLETED"), ] rcv_events_list = list() for event_type, data in engine_events: @@ -106,36 +105,36 @@ def yield_events_dag_succeeds(): def yield_events_dag_fails(): """Received event generator. Yields events that look like they were PULLed from server.""" engine_events = [ - ('exec_started', {'item_name': 'Data Connection 1', 'direction': 'BACKWARD'}), + ("exec_started", {"item_name": "Data Connection 1", "direction": ExecutionDirection.BACKWARD}), ( - 'exec_finished', + "exec_finished", { - 'item_name': 'Data Connection 1', - 'direction': 'BACKWARD', - 'state': 'RUNNING', - 'item_state': ItemExecutionFinishState.FAILURE, + "item_name": "Data Connection 1", + "direction": ExecutionDirection.BACKWARD, + "state": "RUNNING", + "item_state": ItemExecutionFinishState.FAILURE, }, ), - ('exec_started', {'item_name': 'Data Connection 1', 'direction': 'FORWARD'}), + ("exec_started", {"item_name": "Data Connection 1", "direction": ExecutionDirection.FORWARD}), ( - 'event_msg', + "event_msg", { - 'item_name': 'Data Connection 1', - 'filter_id': '', - 'msg_type': 'msg_success', - 'msg_text': 'Executing Data Connection Data Connection 1 finished', + "item_name": "Data Connection 1", + "filter_id": "", + "msg_type": "msg_success", + "msg_text": "Executing Data Connection Data Connection 1 finished", }, ), ( - 'exec_finished', + "exec_finished", { - 'item_name': 'Data Connection 1', - 'direction': 'FORWARD', - 'state': 'RUNNING', - 'item_state': ItemExecutionFinishState.FAILURE, + "item_name": "Data Connection 1", + "direction": ExecutionDirection.FORWARD, + "state": "RUNNING", + "item_state": ItemExecutionFinishState.FAILURE, }, ), - ('dag_exec_finished', 'FAILED'), + ("dag_exec_finished", "FAILED"), ] rcv_events_list = list() for event_type, data in engine_events: diff --git a/tests/spine_db_editor/__init__.py b/tests/spine_db_editor/__init__.py index 154bb8f54..a30ecd9f5 100644 --- a/tests/spine_db_editor/__init__.py +++ b/tests/spine_db_editor/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for Spine db editor -""" +"""Unit tests for Spine Db editor.""" diff --git a/tests/spine_db_editor/helpers.py b/tests/spine_db_editor/helpers.py new file mode 100644 index 000000000..e32584e01 --- /dev/null +++ b/tests/spine_db_editor/helpers.py @@ -0,0 +1,70 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Helper utilities for Database editor's tests.""" +import unittest +from unittest import mock + +from PySide6.QtWidgets import QApplication + +from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor +from tests.mock_helpers import TestSpineDBManager + + +class TestBase(unittest.TestCase): + """Base class for Database editor's table and tree view tests.""" + + @classmethod + def setUpClass(cls): + cls.db_codename = cls.__name__ + "_db" + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._common_setup("sqlite://", create=True) + + def tearDown(self): + self._common_tear_down() + + def _common_setup(self, url, create): + with mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), mock.patch( + "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show" + ): + mock_settings = mock.MagicMock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self._db_mngr = TestSpineDBManager(mock_settings, None) + logger = mock.MagicMock() + self._db_map = self._db_mngr.get_db_map(url, logger, codename=self.db_codename, create=create) + self._db_editor = SpineDBEditor(self._db_mngr, {url: self.db_codename}) + QApplication.processEvents() + + def _common_tear_down(self): + with mock.patch( + "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state" + ), mock.patch("spinetoolbox.spine_db_manager.QMessageBox"): + self._db_editor.close() + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + self._db_editor.deleteLater() + self._db_editor = None + + def _commit_changes_to_database(self, commit_message): + with mock.patch.object(self._db_editor, "_get_commit_msg") as commit_msg: + commit_msg.return_value = commit_message + self._db_editor.ui.actionCommit.trigger() + + def assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item diff --git a/tests/spine_db_editor/mvcmodels/__init__.py b/tests/spine_db_editor/mvcmodels/__init__.py index b4392eb48..b0ae0d5ed 100644 --- a/tests/spine_db_editor/mvcmodels/__init__.py +++ b/tests/spine_db_editor/mvcmodels/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) diff --git a/tests/spine_db_editor/mvcmodels/test_PivotModel.py b/tests/spine_db_editor/mvcmodels/test_PivotModel.py index aa461ee31..1a8bdc630 100644 --- a/tests/spine_db_editor/mvcmodels/test_PivotModel.py +++ b/tests/spine_db_editor/mvcmodels/test_PivotModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,27 +10,40 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for PivotModel class. -""" - +"""Unit tests for PivotModel class.""" import unittest from spinetoolbox.spine_db_editor.mvcmodels.pivot_model import PivotModel class _Header: + @staticmethod + def accepts(header_id): + return True + def header_data(self, header_id): return header_id -INDEX_IDS = {'test1': _Header(), 'test2': _Header(), 'test3': _Header()} +class _HeaderWithData: + def __init__(self, data: dict): + self.data = data + + @staticmethod + def accepts(header_id): + return True + + def header_data(self, header_id): + return self.data[header_id] + + +INDEX_IDS = {"test1": _Header(), "test2": _Header(), "test3": _Header()} DATA = { - ('a', 'aa', 1): 'value_a_aa_1', - ('a', 'bb', 2): 'value_a_bb_2', - ('b', 'cc', 3): 'value_b_cc_3', - ('c', 'cc', 4): 'value_c_cc_4', - ('d', 'dd', 5): 'value_d_dd_5', - ('e', 'ee', 5): 'value_e_ee_5', + ("a", "aa", 1): "value_a_aa_1", + ("a", "bb", 2): "value_a_bb_2", + ("b", "cc", 3): "value_b_cc_3", + ("c", "cc", 4): "value_c_cc_4", + ("d", "dd", 5): "value_d_dd_5", + ("e", "ee", 5): "value_e_ee_5", } @@ -44,7 +58,7 @@ def test_init_model(self): def test_reset_model(self): """test reset model data""" - row_headers = [('a', 'aa', 1), ('a', 'bb', 2), ('b', 'cc', 3), ('c', 'cc', 4), ('d', 'dd', 5), ('e', 'ee', 5)] + row_headers = [("a", "aa", 1), ("a", "bb", 2), ("b", "cc", 3), ("c", "cc", 4), ("d", "dd", 5), ("e", "ee", 5)] column_headers = [] model = PivotModel() model.reset_model(DATA, INDEX_IDS) @@ -60,12 +74,12 @@ def test_reset_model(self): def test_reset_model_with_pivot(self): """Test set data with pivot and tuple_index_entries""" column_headers = [ - ('a', 'aa', 1), - ('a', 'bb', 2), - ('b', 'cc', 3), - ('c', 'cc', 4), - ('d', 'dd', 5), - ('e', 'ee', 5), + ("a", "aa", 1), + ("a", "bb", 2), + ("b", "cc", 3), + ("c", "cc", 4), + ("d", "dd", 5), + ("e", "ee", 5), ] row_headers = [] model = PivotModel() @@ -83,8 +97,8 @@ def test_set_pivot(self): """Test set_pivot""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - model.set_pivot(['test1', 'test2'], ['test3'], [], ()) - row_headers = [('a', 'aa'), ('a', 'bb'), ('b', 'cc'), ('c', 'cc'), ('d', 'dd'), ('e', 'ee')] + model.set_pivot(["test1", "test2"], ["test3"], [], ()) + row_headers = [("a", "aa"), ("a", "bb"), ("b", "cc"), ("c", "cc"), ("d", "dd"), ("e", "ee")] column_headers = [(1,), (2,), (3,), (4,), (5,)] self.assertEqual(model._row_data_header, row_headers) self.assertEqual(model._column_data_header, column_headers) @@ -93,9 +107,9 @@ def test_set_pivot_with_frozen(self): """Test set_pivot with frozen dimension""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - model.set_pivot(['test2'], ['test3'], ['test1'], ('a',)) - row_headers = [('aa',), ('bb',)] - data = [['value_a_aa_1', None], [None, 'value_a_bb_2']] + model.set_pivot(["test2"], ["test3"], ["test1"], ("a",)) + row_headers = [("aa",), ("bb",)] + data = [["value_a_aa_1", None], [None, "value_a_bb_2"]] column_headers = [(1,), (2,)] data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(2), range(2))] self.assertEqual(model._row_data_header, row_headers) @@ -106,8 +120,8 @@ def test_get_pivoted_data1(self): """get data with pivot and frozen index and tuple_index_entries""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - model.set_pivot(['test2'], ['test3'], ['test1'], ('a',)) - data = [['value_a_aa_1', None], [None, 'value_a_bb_2']] + model.set_pivot(["test2"], ["test3"], ["test1"], ("a",)) + data = [["value_a_aa_1", None], [None, "value_a_bb_2"]] data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(2), range(2))] self.assertEqual(data_model, data) @@ -115,14 +129,14 @@ def test_get_pivoted_data2(self): """get data from pivoted model wiht tuple_index_entries""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - model.set_pivot(['test1', 'test2'], ['test3'], [], ()) + model.set_pivot(["test1", "test2"], ["test3"], [], ()) data = [ - ['value_a_aa_1', None, None, None, None], - [None, 'value_a_bb_2', None, None, None], - [None, None, 'value_b_cc_3', None, None], - [None, None, None, 'value_c_cc_4', None], - [None, None, None, None, 'value_d_dd_5'], - [None, None, None, None, 'value_e_ee_5'], + ["value_a_aa_1", None, None, None, None], + [None, "value_a_bb_2", None, None, None], + [None, None, "value_b_cc_3", None, None], + [None, None, None, "value_c_cc_4", None], + [None, None, None, None, "value_d_dd_5"], + [None, None, None, None, "value_e_ee_5"], ] data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(6), range(5))] self.assertEqual(data_model, data) @@ -131,14 +145,14 @@ def test_get_pivoted_data3(self): """get data from pivoted model""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - model.set_pivot(['test1', 'test2'], ['test3'], [], ()) + model.set_pivot(["test1", "test2"], ["test3"], [], ()) data = [ - ['value_a_aa_1', None, None, None, None], - [None, 'value_a_bb_2', None, None, None], - [None, None, 'value_b_cc_3', None, None], - [None, None, None, 'value_c_cc_4', None], - [None, None, None, None, 'value_d_dd_5'], - [None, None, None, None, 'value_e_ee_5'], + ["value_a_aa_1", None, None, None, None], + [None, "value_a_bb_2", None, None, None], + [None, None, "value_b_cc_3", None, None], + [None, None, None, "value_c_cc_4", None], + [None, None, None, None, "value_d_dd_5"], + [None, None, None, None, "value_e_ee_5"], ] data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(6), range(5))] self.assertEqual(data_model, data) @@ -147,63 +161,131 @@ def test_get_unique_index_values1(self): """test that _get_unique_index_values returns unique values for specified indexes""" model = PivotModel() model.reset_model(DATA, INDEX_IDS) - index_set = sorted({('a', 'aa'), ('a', 'bb'), ('b', 'cc'), ('c', 'cc'), ('d', 'dd'), ('e', 'ee')}) - index_header_values = model._get_unique_index_values(('test1', 'test2')) + index_set = sorted({("a", "aa"), ("a", "bb"), ("b", "cc"), ("c", "cc"), ("d", "dd"), ("e", "ee")}) + index_header_values = model._get_unique_index_values(("test1", "test2")) self.assertEqual(index_header_values, index_set) def test_get_unique_index_values2(self): """test that _get_unique_index_values returns unique values for specified indexes with filter index and value""" model = PivotModel() - model.reset_model(DATA, INDEX_IDS, ('test1', 'test2'), (), ('test3',), (5,)) - index_set = sorted({('d', 'dd'), ('e', 'ee')}) - index_header_values = model._get_unique_index_values(('test1', 'test2')) + model.reset_model(DATA, INDEX_IDS, ("test1", "test2"), (), ("test3",), (5,)) + index_set = sorted({("d", "dd"), ("e", "ee")}) + index_header_values = model._get_unique_index_values(("test1", "test2")) self.assertEqual(index_header_values, index_set) def test_add_to_model_replaces_none(self): data = {("a", "aa", 1): None} model = PivotModel() model.reset_model(data, INDEX_IDS) - model.set_pivot(["test1"], ["test2"], ["test3"], ["frozen value"]) + model.set_pivot(["test1"], ["test2"], ["test3"], [1]) model.add_to_model({("a", "aa", 1): 23.0}) self.assertEqual(model._data, {("a", "aa", 1): 23.0}) self.assertEqual(model.index_ids, tuple(INDEX_IDS)) self.assertEqual(model.pivot_rows, ("test1",)) self.assertEqual(model.pivot_columns, ("test2",)) self.assertEqual(model.pivot_frozen, ("test3",)) - self.assertEqual(model.frozen_value, ("frozen value",)) - self.assertEqual(model._row_data_header, []) - self.assertEqual(model._column_data_header, []) + self.assertEqual(model.frozen_value, (1,)) + self.assertEqual(model._row_data_header, [("a",)]) + self.assertEqual(model._column_data_header, [("aa",)]) def test_add_to_model_nones_do_not_overwrite_existing_values(self): data = {("a", "aa", 1): 23.0} model = PivotModel() model.reset_model(data, INDEX_IDS) - model.set_pivot(["test1"], ["test2"], ["test3"], ["frozen value"]) + model.set_pivot(["test1"], ["test2"], ["test3"], [1]) model.add_to_model({("a", "aa", 1): None}) self.assertEqual(model._data, {("a", "aa", 1): 23.0}) self.assertEqual(model.index_ids, tuple(INDEX_IDS)) self.assertEqual(model.pivot_rows, ("test1",)) self.assertEqual(model.pivot_columns, ("test2",)) self.assertEqual(model.pivot_frozen, ("test3",)) - self.assertEqual(model.frozen_value, ("frozen value",)) - self.assertEqual(model._row_data_header, []) - self.assertEqual(model._column_data_header, []) + self.assertEqual(model.frozen_value, (1,)) + self.assertEqual(model._row_data_header, [("a",)]) + self.assertEqual(model._column_data_header, [("aa",)]) def test_add_to_model_nones_can_be_inserted_to_model(self): data = {("a", "aa", 1): 23.0} model = PivotModel() model.reset_model(data, INDEX_IDS) - model.set_pivot(["test1"], ["test2"], ["test3"], ["frozen value"]) + model.set_pivot(["test1"], ["test2"], ["test3"], [1]) model.add_to_model({("a", "aa", 2): None}) self.assertEqual(model._data, {("a", "aa", 1): 23.0, ("a", "aa", 2): None}) self.assertEqual(model.index_ids, tuple(INDEX_IDS)) self.assertEqual(model.pivot_rows, ("test1",)) self.assertEqual(model.pivot_columns, ("test2",)) self.assertEqual(model.pivot_frozen, ("test3",)) - self.assertEqual(model.frozen_value, ("frozen value",)) + self.assertEqual(model.frozen_value, (1,)) + self.assertEqual(model._row_data_header, [("a",)]) + self.assertEqual(model._column_data_header, [("aa",)]) + + def test_remove_single_point_of_data(self): + data = {("a", "aa", 1): 23.0} + model = PivotModel() + model.reset_model(data, INDEX_IDS) + model.set_pivot(["test1"], ["test2"], ["test3"], [1]) + expected_model_data = [ + [23.0], + ] + data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(1), range(1))] + self.assertEqual(data_model, expected_model_data) + model.remove_from_model({("a", "aa", 1): None}) + self.assertEqual(model._data, {}) + self.assertEqual(model.index_ids, tuple(INDEX_IDS)) + self.assertEqual(model.pivot_rows, ("test1",)) + self.assertEqual(model.pivot_columns, ("test2",)) + self.assertEqual(model.pivot_frozen, ("test3",)) + self.assertEqual(model.frozen_value, (1,)) self.assertEqual(model._row_data_header, []) self.assertEqual(model._column_data_header, []) + def test_remove_data_when_entire_column_vanishes(self): + data = { + ("a", "aa", 1): None, + ("a", "bb", 1): None, + ("a", "cc", 1): None, + ("b", "aa", 1): None, + ("b", "bb", 1): 2.3, + ("b", "cc", 1): None, + ("c", "aa", 1): None, + ("c", "bb", 1): None, + ("c", "cc", 1): None, + } + model = PivotModel() + header_data = {"aa": "col1", "bb": "col2", "cc": "col3"} + column_header = _HeaderWithData(header_data) + index_ids = {"test1": _Header(), "test2": column_header, "test3": _Header()} + model.reset_model(data, index_ids) + model.set_pivot(["test1"], ["test2"], ["test3"], [1]) + expected_model_data = [ + [None, None, None], + [None, 2.3, None], + [None, None, None], + ] + data_model = [[d for d in inner] for inner in model.get_pivoted_data(range(3), range(3))] + self.assertEqual(data_model, expected_model_data) + column_header.data["bb"] = None + model.remove_from_model({("a", "bb", 1): None}) + model.remove_from_model({("b", "bb", 1): None}) + model.remove_from_model({("c", "bb", 1): None}) + self.assertEqual( + model._data, + { + ("a", "aa", 1): None, + ("a", "cc", 1): None, + ("b", "aa", 1): None, + ("b", "cc", 1): None, + ("c", "aa", 1): None, + ("c", "cc", 1): None, + }, + ) + self.assertEqual(model.index_ids, tuple(INDEX_IDS)) + self.assertEqual(model.pivot_rows, ("test1",)) + self.assertEqual(model.pivot_columns, ("test2",)) + self.assertEqual(model.pivot_frozen, ("test3",)) + self.assertEqual(model.frozen_value, (1,)) + self.assertEqual(model._row_data_header, [("a",), ("b",), ("c",)]) + self.assertEqual(model._column_data_header, [("aa",), ("cc",)]) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_PivotTableModel.py b/tests/spine_db_editor/mvcmodels/test_PivotTableModel.py index f788e7da4..04e25aae6 100644 --- a/tests/spine_db_editor/mvcmodels/test_PivotTableModel.py +++ b/tests/spine_db_editor/mvcmodels/test_PivotTableModel.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,60 +10,70 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for :class:`ParameterValuePivotTableModel` module. -""" +"""Unit tests for `pivot_table_models` module.""" +import itertools import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch from PySide6.QtWidgets import QApplication from spinedb_api import Map -from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from ...mock_helpers import TestSpineDBManager - - -class TestParameterValuePivotTableModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - with patch.object(SpineDBEditor, "restore_ui"): - self._editor = SpineDBEditor(self._db_mngr, {"sqlite://": db_map.codename}) +from tests.mock_helpers import fetch_model +from tests.spine_db_editor.helpers import TestBase + + +class TestParameterValuePivotTableModel(TestBase): + def _fill_model_with_data(self): data = { - "object_classes": ("class1",), - "object_parameters": (("class1", "parameter1"), ("class1", "parameter2")), - "objects": (("class1", "object1"), ("class1", "object2")), - "object_parameter_values": ( + "entity_classes": (("class1",),), + "parameter_definitions": (("class1", "parameter1"), ("class1", "parameter2")), + "entities": (("class1", "object1"), ("class1", "object2")), + "parameter_values": ( ("class1", "object1", "parameter1", 1.0), ("class1", "object2", "parameter1", 3.0), ("class1", "object1", "parameter2", 5.0), ("class1", "object2", "parameter2", 7.0), ), } - self._db_mngr.import_data({db_map: data}) - object_class_index = self._editor.object_tree_model.index(0, 0) - if self._editor.object_tree_model.canFetchMore(object_class_index): - self._editor.object_tree_model.fetchMore(object_class_index) - index = self._editor.object_tree_model.index(0, 0, object_class_index) - self._editor._update_class_attributes(index) - with patch.object(self._editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: + self._db_mngr.import_data({self._db_map: data}) + while self._db_editor.entity_tree_model._root_item.row_count() == 0: + QApplication.processEvents() + + def _start(self): + get_item_exceptions = [] + + def guarded_get_item(db_map, item_type, id_): + try: + return db_map.get_item(item_type, id=id_) + except Exception as error: + get_item_exceptions.append(error) + return None + + object_class_index = self._db_editor.entity_tree_model.index(0, 0) + fetch_model(self._db_editor.entity_tree_model) + index = self._db_editor.entity_tree_model.index(0, 0, object_class_index) + self._db_editor._update_class_attributes(index) + with patch.object(self._db_editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: mock_is_visible.return_value = True - self._editor.do_reload_pivot_table() - self._model = self._editor.pivot_table_model - self._model.beginResetModel() - self._model.endResetModel() - qApp.processEvents() + self._db_editor.do_reload_pivot_table() + self._model = self._db_editor.pivot_table_model + with patch.object(self._db_mngr, "get_item") as get_item: + get_item.side_effect = guarded_get_item + self._model.beginResetModel() + self._model.endResetModel() + qApp.processEvents() + self.assertEqual(get_item_exceptions, []) - def tearDown(self): - self._db_mngr.close_all_sessions() - self._db_mngr.clean_up() + def _model_data(self): + data = [] + for row in range(self._model.rowCount()): + row_data = [] + for column in range(self._model.columnCount()): + row_data.append(self._model.index(row, column).data()) + data.append(row_data) + return data def test_x_flag(self): + self._fill_model_with_data() + self._start() self.assertIsNone(self._model.plot_x_column) self._model.set_plot_x_column(1, True) self.assertEqual(self._model.plot_x_column, 1) @@ -70,109 +81,250 @@ def test_x_flag(self): self.assertIsNone(self._model.plot_x_column) def test_header_name(self): + self._fill_model_with_data() + self._start() self.assertEqual(self._model.rowCount(), 5) self.assertEqual(self._model.columnCount(), 4) - self.assertEqual(self._model.header_name(self._model.index(2, 0)), 'object1') - self.assertEqual(self._model.header_name(self._model.index(0, 1)), 'parameter1') - self.assertEqual(self._model.header_name(self._model.index(3, 0)), 'object2') - self.assertEqual(self._model.header_name(self._model.index(0, 2)), 'parameter2') + self.assertEqual(self._model.header_name(self._model.index(2, 0)), "object1") + self.assertEqual(self._model.header_name(self._model.index(0, 1)), "parameter1") + self.assertEqual(self._model.header_name(self._model.index(3, 0)), "object2") + self.assertEqual(self._model.header_name(self._model.index(0, 2)), "parameter2") def test_data(self): + self._fill_model_with_data() + self._start() self.assertEqual(self._model.rowCount(), 5) self.assertEqual(self._model.columnCount(), 4) self.assertEqual(self._model.index(0, 0).data(), "parameter") self.assertEqual(self._model.index(1, 0).data(), "class1") self.assertEqual(self._model.index(2, 0).data(), "object1") self.assertEqual(self._model.index(3, 0).data(), "object2") - self.assertEqual(self._model.index(4, 0).data(), None) + self.assertIsNone(self._model.index(4, 0).data()) self.assertEqual(self._model.index(0, 1).data(), "parameter1") - self.assertEqual(self._model.index(1, 1).data(), None) + self.assertIsNone(self._model.index(1, 1).data()) self.assertEqual(self._model.index(2, 1).data(), str(1.0)) self.assertEqual(self._model.index(3, 1).data(), str(3.0)) - self.assertEqual(self._model.index(4, 1).data(), None) + self.assertIsNone(self._model.index(4, 1).data()) self.assertEqual(self._model.index(0, 2).data(), "parameter2") - self.assertEqual(self._model.index(1, 2).data(), None) + self.assertIsNone(self._model.index(1, 2).data()) self.assertEqual(self._model.index(2, 2).data(), str(5.0)) self.assertEqual(self._model.index(3, 2).data(), str(7.0)) - self.assertEqual(self._model.index(4, 2).data(), None) - self.assertEqual(self._model.index(0, 3).data(), None) - self.assertEqual(self._model.index(1, 3).data(), None) - self.assertEqual(self._model.index(2, 3).data(), None) - self.assertEqual(self._model.index(3, 3).data(), None) - self.assertEqual(self._model.index(4, 3).data(), None) + self.assertIsNone(self._model.index(4, 2).data()) + self.assertIsNone(self._model.index(0, 3).data()) + self.assertIsNone(self._model.index(1, 3).data()) + self.assertIsNone(self._model.index(2, 3).data()) + self.assertIsNone(self._model.index(3, 3).data()) + self.assertIsNone(self._model.index(4, 3).data()) def test_header_row_count(self): + self._fill_model_with_data() + self._start() self.assertEqual(self._model.headerRowCount(), 2) + def test_model_works_even_without_entities(self): + data = { + "entity_classes": (("class1",),), + } + self._db_mngr.import_data({self._db_map: data}) + while self._db_editor.entity_tree_model._root_item.row_count() == 0: + QApplication.processEvents() + self._start() + self.assertEqual(self._model.rowCount(), 3) + self.assertEqual(self._model.columnCount(), 2) + self.assertEqual(self._model.index(0, 0).data(), "parameter") + self.assertEqual(self._model.index(1, 0).data(), "class1") + self.assertIsNone(self._model.index(2, 0).data()) + self.assertIsNone(self._model.index(0, 1).data()) + self.assertIsNone(self._model.index(1, 1).data()) + self.assertIsNone(self._model.index(2, 1).data()) + + def test_single_entity_creates_half_finished_pivot(self): + initial_data = { + "entity_classes": (("Object",),), + "entities": (("Object", "spatula"),), + } + self._db_mngr.import_data({self._db_map: initial_data}) + while self._db_editor.entity_tree_model._root_item.row_count() == 0: + QApplication.processEvents() + self._start() + expected = [["parameter", None], ["Object", None], ["spatula", None], [None, None]] + data = self._model_data() + self.assertEqual(data, expected) -class TestIndexExpansionPivotTableModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() + def test_single_entity_and_parameter_definition_create_empty_value_cell(self): + initial_data = { + "entity_classes": (("Object",),), + "parameter_definitions": (("Object", "x"),), + "entities": (("Object", "spatula"),), + } + self._db_mngr.import_data({self._db_map: initial_data}) + while self._db_editor.entity_tree_model._root_item.row_count() == 0: + QApplication.processEvents() + self._start() + expected = [["parameter", "x", None], ["Object", None, None], ["spatula", None, None], [None, None, None]] + data = self._model_data() + self.assertEqual(data, expected) - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - with patch.object(SpineDBEditor, "restore_ui"): - self._editor = SpineDBEditor(self._db_mngr, {"sqlite://": db_map.codename}) - data = { - "object_classes": ("class1",), - "object_parameters": (("class1", "parameter1"), ("class1", "parameter2")), - "objects": (("class1", "object1"), ("class1", "object2")), - "object_parameter_values": ( - ("class1", "object1", "parameter1", Map(["A", "B"], [1.1, 2.1])), - ("class1", "object2", "parameter1", Map(["C", "D"], [1.2, 2.2])), - ("class1", "object1", "parameter2", Map(["C", "D"], [-1.1, -2.1])), - ("class1", "object2", "parameter2", Map(["A", "B"], [-1.2, -2.2])), - ), + def test_removing_value_from_model_sets_value_cell_to_none(self): + initial_data = { + "entity_classes": (("Object",),), + "entities": (("Object", "spatula"),), + "parameter_definitions": (("Object", "x"),), + "parameter_values": (("Object", "spatula", "x", 2.3),), } - self._db_mngr.import_data({db_map: data}) - object_class_index = self._editor.object_tree_model.index(0, 0) - if self._editor.object_tree_model.canFetchMore(object_class_index): - self._editor.object_tree_model.fetchMore(object_class_index) - index = self._editor.object_tree_model.index(0, 0, object_class_index) - for action in self._editor.pivot_action_group.actions(): - if action.text() == self._editor._INDEX_EXPANSION: + self._db_mngr.import_data({self._db_map: initial_data}) + while self._db_editor.entity_tree_model._root_item.row_count() == 0: + QApplication.processEvents() + self._start() + expected = [["parameter", "x", None], ["Object", None, None], ["spatula", str(2.3), None], [None, None, None]] + data = self._model_data() + self.assertEqual(data, expected) + value_item = self._db_map.get_parameter_value_item( + entity_class_name="Object", + entity_byname=("spatula",), + parameter_definition_name="x", + alternative_name="Base", + ) + value_item.remove() + expected = [["parameter", "x", None], ["Object", None, None], ["spatula", None, None], [None, None, None]] + data = self._model_data() + self.assertEqual(data, expected) + + def test_drag_and_drop_database_from_frozen_table(self): + self._fill_model_with_data() + self._start() + for frozen_column in range(self._db_editor.frozen_table_model.columnCount()): + frozen_index = self._db_editor.frozen_table_model.index(0, frozen_column) + if frozen_index.data() == "database": + break + else: + raise RuntimeError("No 'database' column found in frozen table") + frozen_table_header_widget = self._db_editor.ui.frozen_table.indexWidget(frozen_index) + for row, column in itertools.product( + range(self._db_editor.pivot_table_proxy.rowCount()), range(self._db_editor.pivot_table_proxy.columnCount()) + ): + index_widget = self._db_editor.ui.pivot_table.indexWidget( + self._db_editor.pivot_table_proxy.index(row, column) + ) + if index_widget.identifier == "parameter": + break + else: + raise RuntimeError("No 'parameter' header found") + self._db_editor.handle_header_dropped(frozen_table_header_widget, index_widget) + QApplication.processEvents() + self.assertEqual(self._model.rowCount(), 6) + self.assertEqual(self._model.columnCount(), 4) + expected = [ + ["database", self.db_codename, self.db_codename, self.db_codename, None], + ["parameter", "parameter1", "parameter2", None], + ["class1", None, None, None], + ["object1", "1.0", "5.0", None], + ["object2", "3.0", "7.0", None], + [None, None, None, None], + ] + for row, column in itertools.product(range(self._model.rowCount()), range(self._model.columnCount())): + with self.subTest(row=row, column=column): + self.assertEqual(self._model.index(row, column).data(), expected[row][column]) + + +class TestIndexExpansionPivotTableModel(TestBase): + def _start(self, initial_data): + self._db_mngr.import_data({self._db_map: initial_data}) + object_class_index = self._db_editor.entity_tree_model.index(0, 0) + fetch_model(self._db_editor.entity_tree_model) + index = self._db_editor.entity_tree_model.index(0, 0, object_class_index) + for action in self._db_editor.pivot_action_group.actions(): + if action.text() == self._db_editor._INDEX_EXPANSION: action.trigger() break - self._editor._update_class_attributes(index) - with patch.object(self._editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: + self._db_editor._update_class_attributes(index) + with patch.object(self._db_editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: mock_is_visible.return_value = True - self._editor.do_reload_pivot_table() - self._model = self._editor.pivot_table_model + self._db_editor.do_reload_pivot_table() + self._model = self._db_editor.pivot_table_model self._model.beginResetModel() self._model.endResetModel() qApp.processEvents() - def tearDown(self): - self._db_mngr.close_all_sessions() - self._db_mngr.clean_up() + def _model_data(self): + data = [] + for row in range(self._model.rowCount()): + row_data = [] + for column in range(self._model.columnCount()): + row_data.append(self._model.index(row, column).data()) + data.append(row_data) + return data def test_data(self): - self.assertEqual(self._model.rowCount(), 11) - self.assertEqual(self._model.columnCount(), 5) - model_data = list() - i = self._model.index - for row in range(11): - model_data.append(list(i(row, column).data() for column in range(5))) + initial_data = { + "entity_classes": (("class1",),), + "parameter_definitions": (("class1", "parameter1"), ("class1", "parameter2")), + "entities": (("class1", "object1"), ("class1", "object2")), + "parameter_values": ( + ("class1", "object1", "parameter1", Map(["A", "B"], [1.1, 2.1])), + ("class1", "object2", "parameter1", Map(["C", "D"], [1.2, 2.2])), + ("class1", "object1", "parameter2", Map(["C", "D"], [-1.1, -2.1])), + ("class1", "object2", "parameter2", Map(["A", "B"], [-1.2, -2.2])), + ), + } + self._start(initial_data) + self.assertEqual(self._model.rowCount(), 10) + self.assertEqual(self._model.columnCount(), 4) + model_data = self._model_data() expected = [ - [None, "parameter", "parameter1", "parameter2", None], - ["class1", "index", None, None, None], - ["object1", "A", str(1.1), None, None], - ["object1", "B", str(2.1), None, None], - ["object1", "C", None, str(-1.1), None], - ["object1", "D", None, str(-2.1), None], - ["object2", "A", None, str(-1.2), None], - ["object2", "B", None, str(-2.2), None], - ["object2", "C", str(1.2), None, None], - ["object2", "D", str(2.2), None, None], - [None, None, None, None, None], + [None, "parameter", "parameter1", "parameter2"], + ["class1", "index", None, None], + ["object1", "A", str(1.1), None], + ["object1", "B", str(2.1), None], + ["object1", "C", None, str(-1.1)], + ["object1", "D", None, str(-2.1)], + ["object2", "A", None, str(-1.2)], + ["object2", "B", None, str(-2.2)], + ["object2", "C", str(1.2), None], + ["object2", "D", str(2.2), None], ] self.assertEqual(model_data, expected) + def test_entity_without_parameter_values_does_not_show(self): + initial_data = { + "entity_classes": (("Object",),), + "parameter_definitions": (("Object", "x"),), + "entities": (("Object", "spatula"),), + } + self._start(initial_data) + expected = [ + [None, "parameter"], + ["Object", "index"], + ] + data = self._model_data() + self.assertEqual(data, expected) + + def test_removing_value_from_model_removes_it_from_model(self): + initial_data = { + "entity_classes": (("Object",),), + "entities": (("Object", "spatula"),), + "parameter_definitions": (("Object", "x"),), + "parameter_values": (("Object", "spatula", "x", 2.3),), + } + self._start(initial_data) + expected = [ + [None, "parameter", "x"], + ["Object", "index", None], + ["spatula", "", str(2.3)], + ] + data = self._model_data() + self.assertEqual(data, expected) + value_item = self._db_map.get_parameter_value_item( + entity_class_name="Object", + entity_byname=("spatula",), + parameter_definition_name="x", + alternative_name="Base", + ) + value_item.remove() + expected = [[None, "parameter"], ["Object", "index"]] + data = self._model_data() + self.assertEqual(data, expected) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_alternative_model.py b/tests/spine_db_editor/mvcmodels/test_alternative_model.py index 487610d9e..f7eb28c23 100644 --- a/tests/spine_db_editor/mvcmodels/test_alternative_model.py +++ b/tests/spine_db_editor/mvcmodels/test_alternative_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,14 +9,14 @@ # 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 . ###################################################################################################################### + """Unit tests for :class:`AlternativeModel`.""" -import pickle from pathlib import Path +import pickle from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock, patch from PySide6.QtWidgets import QApplication - from spinetoolbox.spine_db_editor.mvcmodels import mime_types from spinetoolbox.spine_db_editor.mvcmodels.alternative_model import AlternativeModel from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor @@ -23,6 +24,8 @@ class TestAlternativeModel(unittest.TestCase): + db_codename = "alternative_model_test_db" + @classmethod def setUpClass(cls): if not QApplication.instance(): @@ -32,9 +35,9 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) + self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": self.db_codename}) def tearDown(self): with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( @@ -42,7 +45,7 @@ def tearDown(self): ): self._db_editor.close() self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._db_editor.deleteLater() @@ -51,7 +54,7 @@ def test_initial_state(self): model = AlternativeModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() data = model_data_to_dict(model) - expected = [[{"test_db": [["Type new alternative name here...", ""]]}, None]] + expected = [[{self.db_codename: [["Type new alternative name here...", ""]]}, None]] self.assertEqual(data, expected) def test_add_alternatives(self): @@ -63,7 +66,7 @@ def test_add_alternatives(self): expected = [ [ { - "test_db": [ + self.db_codename: [ ["Base", "Base alternative"], ["alternative_1", ""], ["Type new alternative name here...", ""], @@ -78,12 +81,18 @@ def test_update_alternatives(self): model = AlternativeModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() _fetch_all_recursively(model) - self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) + self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1", "id": 2}]}) self._db_mngr.update_alternatives({self._db_map: [{"id": 2, "name": "renamed"}]}) data = model_data_to_dict(model) expected = [ [ - {"test_db": [["Base", "Base alternative"], ["renamed", ""], ["Type new alternative name here...", ""]]}, + { + self.db_codename: [ + ["Base", "Base alternative"], + ["renamed", ""], + ["Type new alternative name here...", ""], + ] + }, None, ] ] @@ -93,10 +102,12 @@ def test_remove_alternatives(self): model = AlternativeModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() _fetch_all_recursively(model) - self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) + self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1", "id": 2}]}) self._db_mngr.remove_items({self._db_map: {"alternative": {2}}}) data = model_data_to_dict(model) - expected = [[{"test_db": [["Base", "Base alternative"], ["Type new alternative name here...", ""]]}, None]] + expected = [ + [{self.db_codename: [["Base", "Base alternative"], ["Type new alternative name here...", ""]]}, None] + ] self.assertEqual(data, expected) def test_mimeData(self): @@ -110,11 +121,13 @@ def test_mimeData(self): self.assertTrue(mime_data.hasText()) self.assertEqual(mime_data.text(), "Base\tBase alternative\r\n") self.assertTrue(mime_data.hasFormat(mime_types.ALTERNATIVE_DATA)) - alternative_data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA)) - self.assertEqual(alternative_data, {self._db_mngr.db_map_key(self._db_map): [1]}) + alternative_data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()) + self.assertEqual(alternative_data, {self._db_mngr.db_map_key(self._db_map): ["Base"]}) class TestAlternativeModelWithTwoDatabases(unittest.TestCase): + db_codename = "alternative_model_with_two_databases_test_db" + @classmethod def setUpClass(cls): if not QApplication.instance(): @@ -127,9 +140,9 @@ def setUp(self): self._db_mngr = TestSpineDBManager(app_settings, None) self._db_map1 = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db_1", create=True) url2 = "sqlite:///" + str(Path(self._temp_dir.name, "db2.sqlite")) - self._db_map2 = self._db_mngr.get_db_map(url2, logger, codename="test_db_2", create=True) + self._db_map2 = self._db_mngr.get_db_map(url2, logger, codename=self.db_codename, create=True) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db_1", url2: "test_db_2"}) + self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db_1", url2: self.db_codename}) def tearDown(self): with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( @@ -137,7 +150,7 @@ def tearDown(self): ): self._db_editor.close() self._db_mngr.close_all_sessions() - while not self._db_map1.connection.closed and not self._db_map2.connection.closed: + while not self._db_map1.closed and not self._db_map2.closed: QApplication.processEvents() self._db_mngr.clean_up() self._db_editor.deleteLater() @@ -155,7 +168,7 @@ def test_paste_alternative_mime_data(self): self.assertEqual(source_index.data(), "my_alternative") mime_data = model.mimeData([source_index]) target_index = model.index(1, 0) - self.assertEqual(target_index.data(), "test_db_2") + self.assertEqual(target_index.data(), self.db_codename) target_item = model.item_from_index(target_index) model.paste_alternative_mime_data(mime_data, target_item) _fetch_all_recursively(model) @@ -173,7 +186,7 @@ def test_paste_alternative_mime_data(self): ], [ { - "test_db_2": [ + self.db_codename: [ ["Base", "Base alternative"], ["my_alternative", "My test alternative"], ["Type new alternative name here...", ""], @@ -187,9 +200,10 @@ def test_paste_alternative_mime_data(self): def _fetch_all_recursively(model): for item in model.visit_all(): - if item.can_fetch_more(): + while item.can_fetch_more(): item.fetch_more() + qApp.processEvents() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_compound_models.py b/tests/spine_db_editor/mvcmodels/test_compound_models.py new file mode 100644 index 000000000..68505f422 --- /dev/null +++ b/tests/spine_db_editor/mvcmodels/test_compound_models.py @@ -0,0 +1,193 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for the models in ``compound_models`` module.""" +import unittest +from spinedb_api import Array, to_database +from spinetoolbox.spine_db_editor.mvcmodels.compound_models import ( + CompoundParameterDefinitionModel, + CompoundParameterValueModel, +) +from tests.mock_helpers import fetch_model +from ..helpers import TestBase + + +class TestCompoundParameterDefinitionModel(TestBase): + def test_horizontal_header(self): + model = CompoundParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + expected_header = [ + "entity_class_name", + "parameter_name", + "value_list_name", + "default_value", + "description", + "database", + ] + header = [model.headerData(i) for i in range(model.columnCount())] + self.assertEqual(header, expected_header) + + def test_data_for_single_parameter_definition(self): + model = CompoundParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) + self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 1, "id": 1}]}) + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 6) + row = [model.index(0, column).data() for column in range(model.columnCount())] + expected = ["oc", "p", None, "None", None, self.db_codename] + self.assertEqual(row, expected) + + def test_data_for_single_parameter_definition_in_multidimensional_entity_class(self): + model = CompoundParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "rc", "dimension_id_list": [1], "id": 2}]}) + self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 2, "id": 1}]}) + self._db_map.fetch_all() + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 6) + row = [model.index(0, column).data() for column in range(model.columnCount())] + expected = ["rc", "p", None, "None", None, self.db_codename] + self.assertEqual(row, expected) + + def test_model_updates_when_entity_class_is_removed(self): + self._db_map.add_entity_class_item(name="oc1") + self._db_map.add_parameter_definition_item(entity_class_name="oc1", name="x") + entity_class_2 = self.assert_success(self._db_map.add_entity_class_item(name="oc2")) + self._db_map.add_parameter_definition_item(entity_class_name="oc2", name="x") + self._db_map.add_entity_class_item(name="rc", dimension_name_list=("oc1", "oc2")) + self._db_map.add_parameter_definition_item(entity_class_name="rc", name="x") + model = CompoundParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + model.set_filter_class_ids({self._db_map: {entity_class_2["id"]}}) + self.assertEqual(model.rowCount(), 4) + self._db_mngr.remove_items({self._db_map: {"entity_class": [entity_class_2["id"]]}}) + self.assertEqual(model.rowCount(), 1) + + def test_index_name_returns_sane_label(self): + self.assert_success(self._db_map.add_entity_class_item(name="Object")) + value, value_type = to_database(Array([2.3])) + self.assert_success( + self._db_map.add_parameter_definition_item( + name="x", entity_class_name="Object", default_value=value, default_type=value_type + ) + ) + model = CompoundParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + index = model.index(0, 3) + self.assertEqual(model.index_name(index), "TestCompoundParameterDefinitionModel_db - x - Object") + + +class TestCompoundParameterValueModel(TestBase): + def test_horizontal_header(self): + model = CompoundParameterValueModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + expected_header = [ + "entity_class_name", + "entity_byname", + "parameter_name", + "alternative_name", + "value", + "database", + ] + header = [model.headerData(i) for i in range(model.columnCount())] + self.assertEqual(header, expected_header) + + def test_data_for_single_parameter(self): + model = CompoundParameterValueModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) + self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 1, "id": 1}]}) + self._db_mngr.add_entities({self._db_map: [{"name": "o", "class_id": 1, "id": 1}]}) + value, value_type = to_database(23.0) + self._db_mngr.add_parameter_values( + { + self._db_map: [ + { + "parameter_definition_id": 1, + "value": value, + "type": value_type, + "entity_id": 1, + "entity_class_id": 1, + "alternative_id": 1, + "id": 1, + } + ] + } + ) + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 6) + row = [model.index(0, column).data() for column in range(model.columnCount())] + expected = ["oc", "o", "p", "Base", "23.0", self.db_codename] + self.assertEqual(row, expected) + + def test_data_for_single_parameter_in_multidimensional_entity(self): + model = CompoundParameterValueModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) + self._db_mngr.add_entities({self._db_map: [{"name": "o", "class_id": 1, "id": 1}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "rc", "dimension_id_list": [1], "id": 2}]}) + self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "entity_class_id": 2, "id": 1}]}) + self._db_mngr.add_entities({self._db_map: [{"name": "r", "class_id": 2, "element_id_list": [1], "id": 2}]}) + value, value_type = to_database(23.0) + self._db_mngr.add_parameter_values( + { + self._db_map: [ + { + "parameter_definition_id": 1, + "value": value, + "type": value_type, + "entity_id": 2, + "entity_class_id": 2, + "alternative_id": 1, + "id": 1, + } + ] + } + ) + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 6) + row = [model.index(0, column).data() for column in range(model.columnCount())] + expected = ["rc", "o", "p", "Base", "23.0", self.db_codename] + self.assertEqual(row, expected) + + def test_index_name_returns_sane_label(self): + self.assert_success(self._db_map.add_entity_class_item(name="Object")) + self.assert_success(self._db_map.add_parameter_definition_item(name="x", entity_class_name="Object")) + self.assert_success(self._db_map.add_entity_item(name="mysterious cube", entity_class_name="Object")) + value, value_type = to_database(Array([2.3])) + self.assert_success( + self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("mysterious cube",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=value_type, + ) + ) + model = CompoundParameterValueModel(self._db_editor, self._db_mngr, self._db_map) + model.init_model() + fetch_model(model) + index = model.index(0, 3) + self.assertEqual(model.index_name(index), "TestCompoundParameterValueModel_db - x - Base - mysterious cube") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_compound_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_compound_parameter_models.py deleted file mode 100644 index a3f5d3a26..000000000 --- a/tests/spine_db_editor/mvcmodels/test_compound_parameter_models.py +++ /dev/null @@ -1,278 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Unit tests for the models in ``compound_parameter_models`` module. -""" -import unittest -from unittest.mock import MagicMock, patch -from PySide6.QtWidgets import QApplication -from spinetoolbox.spine_db_editor.mvcmodels.compound_parameter_models import ( - CompoundObjectParameterDefinitionModel, - CompoundObjectParameterValueModel, - CompoundRelationshipParameterDefinitionModel, - CompoundRelationshipParameterValueModel, -) -from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from ...mock_helpers import TestSpineDBManager - - -class TestCompoundObjectParameterDefinitionModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) - - def tearDown(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.QMessageBox" - ): - self._db_editor.close() - self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - - def test_horizontal_header(self): - model = CompoundObjectParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - expected_header = [ - "object_class_name", - "parameter_name", - "value_list_name", - "default_value", - "description", - "database", - ] - header = [model.headerData(i) for i in range(model.columnCount())] - self.assertEqual(header, expected_header) - - def test_data_for_single_parameter_definition(self): - model = CompoundObjectParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - if model.canFetchMore(None): - model.fetchMore(None) - self._db_mngr.add_object_classes({self._db_map: [{"name": "oc"}]}) - self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "object_class_id": 1}]}) - self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 6) - row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["oc", "p", None, "None", None, "test_db"] - self.assertEqual(row, expected) - - -class TestCompoundRelationshipParameterDefinitionModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) - - def tearDown(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.QMessageBox" - ): - self._db_editor.close() - self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - - def test_horizontal_header(self): - model = CompoundRelationshipParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - expected_header = [ - "relationship_class_name", - "object_class_name_list", - "parameter_name", - "value_list_name", - "default_value", - "description", - "database", - ] - header = [model.headerData(i) for i in range(model.columnCount())] - self.assertEqual(header, expected_header) - - def test_data_for_single_parameter_definition(self): - model = CompoundRelationshipParameterDefinitionModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - if model.canFetchMore(None): - model.fetchMore(None) - self._db_mngr.add_object_classes({self._db_map: [{"name": "oc"}]}) - self._db_mngr.add_relationship_classes({self._db_map: [{"name": "rc", "object_class_id_list": [1]}]}) - self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "relationship_class_id": 2}]}) - self._db_mngr.fetch_all(self._db_map) - self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 7) - row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["rc", "oc", "p", None, "None", None, "test_db"] - self.assertEqual(row, expected) - - -class TestCompoundObjectParameterValueModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - self._db_mngr.fetch_all(self._db_map) - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) - - def tearDown(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.QMessageBox" - ): - self._db_editor.close() - self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - - def test_horizontal_header(self): - model = CompoundObjectParameterValueModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - expected_header = [ - "object_class_name", - "object_name", - "parameter_name", - "alternative_name", - "value", - "database", - ] - header = [model.headerData(i) for i in range(model.columnCount())] - self.assertEqual(header, expected_header) - - def test_data_for_single_parameter(self): - model = CompoundObjectParameterValueModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - if model.canFetchMore(None): - model.fetchMore(None) - self._db_mngr.add_object_classes({self._db_map: [{"name": "oc"}]}) - self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "object_class_id": 1}]}) - self._db_mngr.add_objects({self._db_map: [{"name": "o", "class_id": 1}]}) - self._db_mngr.add_parameter_values( - { - self._db_map: [ - { - "parameter_definition_id": 1, - "value": b"23.0", - "type": None, - "object_id": 1, - "object_class_id": 1, - "alternative_id": 1, - } - ] - } - ) - self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 6) - row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["oc", "o", "p", "Base", "23.0", "test_db"] - self.assertEqual(row, expected) - - -class TestCompoundRelationshipParameterValueModel(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - app_settings = MagicMock() - logger = MagicMock() - self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) - self._db_mngr.fetch_all(self._db_map) - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) - - def tearDown(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.QMessageBox" - ): - self._db_editor.close() - self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - - def test_horizontal_header(self): - model = CompoundRelationshipParameterValueModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - expected_header = [ - "relationship_class_name", - "object_name_list", - "parameter_name", - "alternative_name", - "value", - "database", - ] - header = [model.headerData(i) for i in range(model.columnCount())] - self.assertEqual(header, expected_header) - - def test_data_for_single_parameter(self): - model = CompoundRelationshipParameterValueModel(self._db_editor, self._db_mngr, self._db_map) - model.init_model() - if model.canFetchMore(None): - model.fetchMore(None) - self._db_mngr.add_object_classes({self._db_map: [{"name": "oc"}]}) - self._db_mngr.add_objects({self._db_map: [{"name": "o", "class_id": 1}]}) - self._db_mngr.add_relationship_classes({self._db_map: [{"name": "rc", "object_class_id_list": [1]}]}) - self._db_mngr.add_parameter_definitions({self._db_map: [{"name": "p", "relationship_class_id": 2}]}) - self._db_mngr.add_relationships({self._db_map: [{"name": "r", "class_id": 2, "object_id_list": [1]}]}) - self._db_mngr.add_parameter_values( - { - self._db_map: [ - { - "parameter_definition_id": 1, - "value": b"23.0", - "type": None, - "relationship_id": 2, - "relationship_class_id": 2, - "alternative_id": 1, - } - ] - } - ) - self.assertEqual(model.rowCount(), 2) - self.assertEqual(model.columnCount(), 6) - row = [model.index(0, column).data() for column in range(model.columnCount())] - expected = ["rc", "o", "p", "Base", "23.0", "test_db"] - self.assertEqual(row, expected) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py index 95871e170..d243ce658 100644 --- a/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py +++ b/tests/spine_db_editor/mvcmodels/test_emptyParameterModels.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,12 +10,9 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the EmptyParameterModel subclasses. -""" +"""Unit tests for the EmptyParameterModel subclasses.""" import unittest from unittest import mock -from PySide6.QtCore import QModelIndex from PySide6.QtWidgets import QApplication from spinedb_api import ( import_object_classes, @@ -24,15 +22,24 @@ import_relationship_parameters, import_relationships, ) -from spinetoolbox.spine_db_editor.mvcmodels.empty_parameter_models import ( - EmptyObjectParameterValueModel, - EmptyRelationshipParameterValueModel, - EmptyObjectParameterDefinitionModel, - EmptyRelationshipParameterDefinitionModel, +from spinetoolbox.spine_db_editor.mvcmodels.empty_models import EmptyParameterValueModel, EmptyParameterDefinitionModel +from spinetoolbox.spine_db_editor.mvcmodels.compound_models import ( + CompoundParameterValueModel, + CompoundParameterDefinitionModel, ) from spinetoolbox.helpers import DB_ITEM_SEPARATOR from spinedb_api.parameter_value import join_value_and_type -from ...mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager, fetch_model + + +class TestEmptyParameterDefinitionModel(EmptyParameterDefinitionModel): + def __init__(self, db_mngr): + super().__init__(CompoundParameterDefinitionModel(None, db_mngr)) + + +class TestEmptyParameterValueModel(EmptyParameterValueModel): + def __init__(self, db_mngr): + super().__init__(CompoundParameterValueModel(None, db_mngr)) def _empty_indexes(model): @@ -58,23 +65,7 @@ def setUp(self): import_relationship_parameters(self._db_map, (("dog__fish", "relative_speed"),)) import_relationships(self._db_map, (("dog__fish", ("pluto", "nemo")),)) self._db_map.commit_session("Add test data") - self._db_mngr.fetch_all(self._db_map) - self.object_table_header = [ - "object_class_name", - "object_name", - "parameter_name", - "alternative_id", - "value", - "database", - ] - self.relationship_table_header = [ - "relationship_class_name", - "object_name_list", - "parameter_name", - "alternative_id", - "value", - "database", - ] + self._db_map.fetch_all() def tearDown(self): self._db_mngr.close_all_sessions() @@ -82,59 +73,53 @@ def tearDown(self): def test_add_object_parameter_values_to_db(self): """Test that object parameter values are added to the db when editing the table.""" - header = self.object_table_header - model = EmptyObjectParameterValueModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) + model = TestEmptyParameterValueModel(self._db_mngr) + fetch_model(model) self.assertTrue( model.batch_set_data( _empty_indexes(model), - ["dog", "pluto", "breed", 1, join_value_and_type(b'"bloodhound"', None), "mock_db"], + ["dog", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"], ) ) - values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["object_class_id"]] + values = self._db_mngr.get_items(self._db_map, "parameter_value") self.assertEqual(len(values), 1) - self.assertEqual(values[0]["object_class_name"], "dog") - self.assertEqual(values[0]["object_name"], "pluto") + self.assertEqual(values[0]["entity_class_name"], "dog") + self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") self.assertEqual(values[0]["value"], b'"bloodhound"') def test_do_not_add_invalid_object_parameter_values(self): """Test that object parameter values aren't added to the db if data is incomplete.""" - header = self.object_table_header - model = EmptyObjectParameterValueModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) - self.assertTrue(model.batch_set_data(_empty_indexes(model), ["fish", "nemo", "water", "salty", "mock_db"])) - values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["object_class_id"]] + model = TestEmptyParameterValueModel(self._db_mngr) + fetch_model(model) + self.assertTrue( + model.batch_set_data(_empty_indexes(model), ["fish", "nemo", "water", "Base", "salty", "mock_db"]) + ) + values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if not x["dimension_id_list"]] self.assertEqual(values, []) def test_infer_class_from_object_and_parameter(self): """Test that object classes are inferred from the object and parameter if possible.""" - header = self.object_table_header - model = EmptyObjectParameterValueModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) + model = TestEmptyParameterValueModel(self._db_mngr) + fetch_model(model) indexes = _empty_indexes(model) self.assertTrue( model.batch_set_data( - indexes, ["cat", "pluto", "breed", 1, join_value_and_type(b'"bloodhound"', None), "mock_db"] + indexes, ["cat", "pluto", "breed", "Base", join_value_and_type(b'"bloodhound"', None), "mock_db"] ) ) self.assertEqual(indexes[0].data(), "dog") - values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["object_class_id"]] + values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if not x["dimension_id_list"]] self.assertEqual(len(values), 1) - self.assertEqual(values[0]["object_class_name"], "dog") - self.assertEqual(values[0]["object_name"], "pluto") + self.assertEqual(values[0]["entity_class_name"], "dog") + self.assertEqual(values[0]["entity_name"], "pluto") self.assertEqual(values[0]["parameter_name"], "breed") self.assertEqual(values[0]["value"], b'"bloodhound"') def test_add_relationship_parameter_values_to_db(self): """Test that relationship parameter values are added to the db when editing the table.""" - header = self.relationship_table_header - model = EmptyRelationshipParameterValueModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) + model = TestEmptyParameterValueModel(self._db_mngr) + fetch_model(model) self.assertTrue( model.batch_set_data( _empty_indexes(model), @@ -142,79 +127,77 @@ def test_add_relationship_parameter_values_to_db(self): "dog__fish", DB_ITEM_SEPARATOR.join(["pluto", "nemo"]), "relative_speed", - 1, + "Base", join_value_and_type(b"-1", None), "mock_db", ], ) ) - values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["relationship_class_id"]] + values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["dimension_id_list"]] self.assertEqual(len(values), 1) - self.assertEqual(values[0]["relationship_class_name"], "dog__fish") - self.assertEqual(values[0]["object_name_list"], ("pluto", "nemo")) + self.assertEqual(values[0]["entity_class_name"], "dog__fish") + self.assertEqual(values[0]["element_name_list"], ("pluto", "nemo")) self.assertEqual(values[0]["parameter_name"], "relative_speed") self.assertEqual(values[0]["value"], b"-1") def test_do_not_add_invalid_relationship_parameter_values(self): """Test that relationship parameter values aren't added to the db if data is incomplete.""" - header = self.relationship_table_header - model = EmptyRelationshipParameterValueModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) + model = TestEmptyParameterValueModel(self._db_mngr) + fetch_model(model) self.assertTrue( model.batch_set_data(_empty_indexes(model), ["dog__fish", "pluto,nemo", "combined_mojo", 100, "mock_db"]) ) - values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["relationship_class_id"]] + values = [x for x in self._db_mngr.get_items(self._db_map, "parameter_value") if x["dimension_id_list"]] self.assertEqual(values, []) def test_add_object_parameter_definitions_to_db(self): """Test that object parameter definitions are added to the db when editing the table.""" - header = ["object_class_name", "parameter_name", "value_list_name", "database"] - model = EmptyObjectParameterDefinitionModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) - self.assertTrue(model.batch_set_data(_empty_indexes(model), ["dog", "color", None, "mock_db"])) - definitions = [x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["object_class_id"]] + model = TestEmptyParameterDefinitionModel(self._db_mngr) + fetch_model(model) + self.assertTrue(model.batch_set_data(_empty_indexes(model), ["dog", "color", None, None, None, "mock_db"])) + definitions = [ + x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if not x["dimension_id_list"] + ] self.assertEqual(len(definitions), 2) - names = {d["parameter_name"] for d in definitions} + names = {d["name"] for d in definitions} self.assertEqual(names, {"breed", "color"}) def test_do_not_add_invalid_object_parameter_definitions(self): """Test that object parameter definitions aren't added to the db if data is incomplete.""" - header = self.object_table_header - model = EmptyObjectParameterDefinitionModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) - self.assertTrue(model.batch_set_data(_empty_indexes(model), ["cat", "color", None, "mock_db"])) - definitions = [x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["object_class_id"]] + model = TestEmptyParameterDefinitionModel(self._db_mngr) + fetch_model(model) + self.assertTrue(model.batch_set_data(_empty_indexes(model), ["cat", "color", None, None, None, "mock_db"])) + definitions = [ + x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if not x["dimension_id_list"] + ] self.assertEqual(len(definitions), 1) - self.assertEqual(definitions[0]["parameter_name"], "breed") + self.assertEqual(definitions[0]["name"], "breed") def test_add_relationship_parameter_definitions_to_db(self): """Test that relationship parameter definitions are added to the db when editing the table.""" - header = ["relationship_class_name", "parameter_name", "value_list_name", "database"] - model = EmptyRelationshipParameterDefinitionModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) - self.assertTrue(model.batch_set_data(_empty_indexes(model), ["dog__fish", "combined_mojo", None, "mock_db"])) + model = TestEmptyParameterDefinitionModel(self._db_mngr) + fetch_model(model) + self.assertTrue( + model.batch_set_data(_empty_indexes(model), ["dog__fish", "combined_mojo", None, None, None, "mock_db"]) + ) definitions = [ - x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["relationship_class_id"] + x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["dimension_id_list"] ] self.assertEqual(len(definitions), 2) - names = {d["parameter_name"] for d in definitions} + names = {d["name"] for d in definitions} self.assertEqual(names, {"relative_speed", "combined_mojo"}) def test_do_not_add_invalid_relationship_parameter_definitions(self): """Test that relationship parameter definitions aren't added to the db if data is incomplete.""" - header = self.relationship_table_header - model = EmptyRelationshipParameterDefinitionModel(None, header, self._db_mngr) - if model.canFetchMore(QModelIndex()): - model.fetchMore(QModelIndex()) + model = TestEmptyParameterDefinitionModel(self._db_mngr) + fetch_model(model) self.assertTrue( - model.batch_set_data(_empty_indexes(model), ["fish__dog", "each_others_opinion", None, "mock_db"]) + model.batch_set_data( + _empty_indexes(model), ["fish__dog", "each_others_opinion", None, None, None, "mock_db"] + ) ) definitions = [ - x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["relationship_class_id"] + x for x in self._db_mngr.get_items(self._db_map, "parameter_definition") if x["dimension_id_list"] ] self.assertEqual(len(definitions), 1) - self.assertEqual(definitions[0]["parameter_name"], "relative_speed") + self.assertEqual(definitions[0]["name"], "relative_speed") diff --git a/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py b/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py index 9aed58f6f..5e325e47f 100644 --- a/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_frozen_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,18 +9,19 @@ # 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 . ###################################################################################################################### + """Contains unit tests for the ``frozen_table_model`` module.""" import unittest from unittest.mock import MagicMock - -from PySide6.QtCore import QModelIndex, QObject +from PySide6.QtCore import QModelIndex, QObject, Qt from PySide6.QtWidgets import QApplication - from spinetoolbox.spine_db_editor.mvcmodels.frozen_table_model import FrozenTableModel -from tests.mock_helpers import TestSpineDBManager +from tests.mock_helpers import model_data_to_table, TestSpineDBManager class TestFrozenTableModel(unittest.TestCase): + db_codename = "frozen_table_model_test_db" + @classmethod def setUpClass(cls): if not QApplication.instance(): @@ -29,14 +31,14 @@ def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) self._parent = QObject() self._model = FrozenTableModel(self._db_mngr, self._parent) def tearDown(self): self._parent.deleteLater() self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() @@ -74,7 +76,7 @@ def test_add_values(self): self.assertEqual(self._model.index(0, 0).data(), "alternative") self.assertEqual(self._model.index(0, 1).data(), "database") self.assertEqual(self._model.index(1, 0).data(), "Base") - self.assertEqual(self._model.index(1, 1).data(), "test_db") + self.assertEqual(self._model.index(1, 1).data(), self.db_codename) def test_remove_values_before_selected_row(self): self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) @@ -92,6 +94,27 @@ def test_remove_values_before_selected_row(self): self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.get_frozen_value(), ((self._db_map, frozen_alternative_id), self._db_map)) + def test_remove_selected_row_when_selected_row_gets_updated_during_removal(self): + self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) + alternatives = self._db_mngr.get_items(self._db_map, "alternative") + ids = {item["id"] for item in alternatives} + self._model.set_headers(["alternative", "database"]) + values = {((self._db_map, id_), self._db_map) for id_ in ids} + self._model.add_values(values) + self.assertEqual(self._model.rowCount(), 3) + self._model.set_selected(2) + # Simulate tabular_view_mixin and frozen table view here. + row_removal_handler = MagicMock() + row_removal_handler.side_effect = lambda *args: self._model.set_selected(1) + self._model.rowsAboutToBeRemoved.connect(row_removal_handler) + frozen_value = self._model.get_frozen_value() + id_to_remove = frozen_value[0][1] + self._model.remove_values({((self._db_map, id_to_remove), self._db_map)}) + row_removal_handler.assert_called_once() + self.assertEqual(self._model.rowCount(), 2) + base_alternative_id = self._db_map.get_alternative_item(name="Base")["id"] + self.assertEqual(self._model.get_frozen_value(), ((self._db_map, base_alternative_id), self._db_map)) + def test_remove_values_after_selected_row(self): self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) alternatives = self._db_mngr.get_items(self._db_map, "alternative") @@ -143,7 +166,7 @@ def test_insert_column_data_extends_existing_data_in_model(self): self.assertEqual(self._model.columnCount(), 2) self.assertEqual(self._model.rowCount(), 3) self.assertEqual(self._model.headers, ["database", "alternative"]) - expected = [["test_db", "Base"], ["test_db", "alternative_1"]] + expected = [[self.db_codename, "Base"], [self.db_codename, "alternative_1"]] for row in range(1, self._model.rowCount()): for column in range(self._model.columnCount()): with self.subTest(f"row {row} column {column}"): @@ -160,7 +183,7 @@ def test_insert_column_data_extends_inserted_data(self): self.assertEqual(self._model.columnCount(), 2) self.assertEqual(self._model.rowCount(), 3) self.assertEqual(self._model.headers, ["database", "alternative"]) - expected = [["test_db", "Base"], ["test_db", "alternative_1"]] + expected = [[self.db_codename, "Base"], [self.db_codename, "alternative_1"]] for row in range(1, self._model.rowCount()): for column in range(self._model.columnCount()): with self.subTest(f"row {row} column {column}"): @@ -186,7 +209,7 @@ def test_remove_column_shortens_existing_data(self): self.assertEqual(self._model.columnCount(), 1) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.headers, ["database"]) - self.assertEqual(self._model.index(1, 0).data(), "test_db") + self.assertEqual(self._model.index(1, 0).data(), self.db_codename) def test_move_columns(self): self._model.insert_column_data("database", {self._db_map}, 0) @@ -201,7 +224,7 @@ def test_move_columns(self): self.assertEqual(self._model.columnCount(), 2) self.assertEqual(self._model.rowCount(), 3) self.assertEqual(self._model.headers, ["alternative", "database"]) - expected = [["Base", "test_db"], ["alternative_1", "test_db"]] + expected = [["Base", self.db_codename], ["alternative_1", self.db_codename]] for row in range(1, self._model.rowCount()): for column in range(self._model.columnCount()): with self.subTest(f"row {row} column {column}"): @@ -209,13 +232,13 @@ def test_move_columns(self): def test_table_stays_sorted(self): self._model.insert_column_data("database", {self._db_map}, 0) - self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) - self._db_mngr.add_object_classes({self._db_map: [{"name": "Gadget"}]}) - self._db_mngr.add_objects({self._db_map: [{"class_id": 1, "name": "fork"}, {"class_id": 1, "name": "spoon"}]}) + self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1", "id": 2}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "Gadget", "id": 1}]}) + self._db_mngr.add_entities({self._db_map: [{"class_id": 1, "name": "fork"}, {"class_id": 1, "name": "spoon"}]}) alternatives = self._db_mngr.get_items(self._db_map, "alternative") ids = {item["id"] for item in alternatives} self._model.insert_column_data("alternative", {(self._db_map, id_) for id_ in ids}, 0) - objects = self._db_mngr.get_items(self._db_map, "object") + objects = self._db_mngr.get_items(self._db_map, "entity") ids = {item["id"] for item in objects} self._model.insert_column_data("Gadget", {(self._db_map, id_) for id_ in ids}, 0) self.assertEqual(self._model.headers, ["Gadget", "alternative", "database"]) @@ -226,17 +249,29 @@ def test_table_stays_sorted(self): self.assertEqual(self._model.index(0, 2).data(), "database") self.assertEqual(self._model.index(1, 0).data(), "fork") self.assertEqual(self._model.index(1, 1).data(), "Base") - self.assertEqual(self._model.index(1, 2).data(), "test_db") + self.assertEqual(self._model.index(1, 2).data(), self.db_codename) self.assertEqual(self._model.index(2, 0).data(), "fork") self.assertEqual(self._model.index(2, 1).data(), "alternative_1") - self.assertEqual(self._model.index(2, 2).data(), "test_db") + self.assertEqual(self._model.index(2, 2).data(), self.db_codename) self.assertEqual(self._model.index(3, 0).data(), "spoon") self.assertEqual(self._model.index(3, 1).data(), "Base") - self.assertEqual(self._model.index(3, 2).data(), "test_db") + self.assertEqual(self._model.index(3, 2).data(), self.db_codename) self.assertEqual(self._model.index(4, 0).data(), "spoon") self.assertEqual(self._model.index(4, 1).data(), "alternative_1") - self.assertEqual(self._model.index(4, 2).data(), "test_db") + self.assertEqual(self._model.index(4, 2).data(), self.db_codename) + + def test_tooltips_work_when_no_data_is_available(self): + self._model.insert_column_data("database", {self._db_map}, 0) + self._db_mngr.remove_items({self._db_map: {"alternative": [1]}}) + self._model.insert_column_data("alternative", {(self._db_map, None)}, 1) + self.assertEqual(self._model.headers, ["database", "alternative"]) + model_data = model_data_to_table(self._model) + expected = [["database", "alternative"], [self.db_codename, None]] + self.assertEqual(model_data, expected) + tool_tip_data = model_data_to_table(self._model, QModelIndex(), Qt.ItemDataRole.ToolTipRole) + expected = [["database", "alternative"], [f"{self.db_codename}", None]] + self.assertEqual(tool_tip_data, expected) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py b/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py index 3eae422b7..565f2786b 100644 --- a/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_item_metadata_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the item metadata table model. -""" +"""Unit tests for the item metadata table model.""" from tempfile import TemporaryDirectory import unittest from unittest import mock @@ -35,7 +34,7 @@ ) from spinetoolbox.spine_db_editor.mvcmodels.item_metadata_table_model import ItemMetadataTableModel from spinetoolbox.spine_db_editor.mvcmodels.metadata_table_model_base import Column -from ...mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager, fetch_model class TestItemMetadataTableModelWithExistingData(unittest.TestCase): @@ -52,17 +51,15 @@ def setUp(self): import_objects(db_map, (("my_class", "my_object"),)) import_object_parameters(db_map, (("my_class", "object_parameter"),)) import_object_parameter_values(db_map, (("my_class", "my_object", "object_parameter", 2.3),)) - import_relationship_classes(db_map, (("relationship_class", ("my_class",)),)) - import_relationships(db_map, (("relationship_class", ("my_object",)),)) - import_relationship_parameters(db_map, (("relationship_class", "relationship_parameter"),)) - import_relationship_parameter_values( - db_map, (("relationship_class", ("my_object",), "relationship_parameter", 5.0),) - ) + import_relationship_classes(db_map, (("entity_class", ("my_class",)),)) + import_relationships(db_map, (("entity_class", ("my_object",)),)) + import_relationship_parameters(db_map, (("entity_class", "relationship_parameter"),)) + import_relationship_parameter_values(db_map, (("entity_class", ("my_object",), "relationship_parameter", 5.0),)) import_metadata(db_map, ('{"source": "Fountain of objects"}',)) import_object_metadata(db_map, (("my_class", "my_object", '{"source": "Fountain of objects"}'),)) import_metadata(db_map, ('{"source": "Fountain of relationships"}',)) import_relationship_metadata( - db_map, (("relationship_class", ("my_object",), '{"source": "Fountain of relationships"}'),) + db_map, (("entity_class", ("my_object",), '{"source": "Fountain of relationships"}'),) ) import_metadata(db_map, ('{"source": "Fountain of object values"}',)) import_object_parameter_value_metadata( @@ -73,7 +70,7 @@ def setUp(self): db_map, ( ( - "relationship_class", + "entity_class", ("my_object",), "relationship_parameter", '{"source": "Fountain of relationship values"}', @@ -81,21 +78,20 @@ def setUp(self): ), ) db_map.commit_session("Add test data.") - db_map.connection.close() + db_map.close() mock_settings = mock.Mock() mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = TestSpineDBManager(mock_settings, None) logger = mock.MagicMock() self._db_map = self._db_mngr.get_db_map(self._url, logger, codename="database") QApplication.processEvents() - self._db_mngr.get_db_map_cache(self._db_map) + self._db_map.fetch_all() self._model = ItemMetadataTableModel(self._db_mngr, [self._db_map], None) - if self._model.canFetchMore(None): - self._model.fetchMore(None) + fetch_model(self._model) def tearDown(self): self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._model.deleteLater() @@ -110,7 +106,7 @@ def test_model_is_initially_empty(self): self._assert_empty_last_row() def test_get_metadata_for_object(self): - self._model.set_entity_ids({self._db_map: 1}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=1)["id"]}) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "source") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Fountain of objects") @@ -118,7 +114,7 @@ def test_get_metadata_for_object(self): self._assert_empty_last_row() def test_get_metadata_for_relationship(self): - self._model.set_entity_ids({self._db_map: 2}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=2)["id"]}) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "source") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Fountain of relationships") @@ -126,7 +122,7 @@ def test_get_metadata_for_relationship(self): self._assert_empty_last_row() def test_get_metadata_for_object_parameter_value(self): - self._model.set_parameter_value_ids({self._db_map: 1}) + self._model.set_parameter_value_ids({self._db_map: self._db_map.get_parameter_value_item(id=1)["id"]}) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "source") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Fountain of object values") @@ -134,7 +130,7 @@ def test_get_metadata_for_object_parameter_value(self): self._assert_empty_last_row() def test_get_metadata_for_relationship_parameter_value(self): - self._model.set_parameter_value_ids({self._db_map: 2}) + self._model.set_parameter_value_ids({self._db_map: self._db_map.get_parameter_value_item(id=2)["id"]}) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "source") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Fountain of relationship values") @@ -148,7 +144,7 @@ def _assert_empty_last_row(self): self.assertEqual(self._model.index(row, Column.DB_MAP).data(), "database") def test_roll_back_after_item_metadata_update(self): - self._model.set_entity_ids({self._db_map: 1}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=1)["id"]}) index = self._model.index(0, Column.VALUE) self.assertTrue(self._model.setData(index, "Magician's hat")) self.assertEqual(self._model.rowCount(), 2) @@ -156,14 +152,13 @@ def test_roll_back_after_item_metadata_update(self): self.assertEqual(self._model.index(0, Column.VALUE).data(), "Magician's hat") self._assert_empty_last_row() self._db_mngr.rollback_session(self._db_map) - self._model.rollback([self._db_map]) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "source") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Fountain of objects") self._assert_empty_last_row() def test_update_relationship_parameter_value_metadata(self): - self._model.set_parameter_value_ids({self._db_map: 2}) + self._model.set_parameter_value_ids({self._db_map: self._db_map.get_parameter_value_item(id=2)["id"]}) index = self._model.index(0, Column.VALUE) self.assertTrue(self._model.setData(index, "Magician's hat")) self.assertEqual(self._model.rowCount(), 2) @@ -172,7 +167,7 @@ def test_update_relationship_parameter_value_metadata(self): self._assert_empty_last_row() def test_update_relationship_metadata(self): - self._model.set_entity_ids({self._db_map: 2}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=2)["id"]}) index = self._model.index(0, Column.VALUE) self.assertTrue(self._model.setData(index, "Magician's hat")) self.assertEqual(self._model.rowCount(), 2) @@ -181,21 +176,14 @@ def test_update_relationship_metadata(self): self._assert_empty_last_row() def test_add_relationship_parameter_value_metadata(self): - self._model.set_parameter_value_ids({self._db_map: 2}) + self._model.set_parameter_value_ids({self._db_map: self._db_map.get_parameter_value_item(id=2)["id"]}) index = self._model.index(1, Column.NAME) self.assertTrue(self._model.setData(index, "author")) index = self._model.index(1, Column.VALUE) self.assertTrue(self._model.setData(index, "Anonymous")) db_map_item_metadata = { self._db_map: [ - { - "id": 3, - "metadata_id": 5, - "metadata_name": "author", - "metadata_value": "Anonymous", - "parameter_value_id": 2, - "commit_id": None, - } + {"metadata_name": "author", "metadata_value": "Anonymous", "parameter_value_id": 2, "commit_id": None} ] } self._db_mngr.add_parameter_value_metadata(db_map_item_metadata) @@ -207,7 +195,7 @@ def test_add_relationship_parameter_value_metadata(self): self._assert_empty_last_row() def test_add_relationship_metadata(self): - self._model.set_entity_ids({self._db_map: 2}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=2)["id"]}) index = self._model.index(1, Column.NAME) self.assertTrue(self._model.setData(index, "author")) index = self._model.index(1, Column.VALUE) @@ -215,8 +203,6 @@ def test_add_relationship_metadata(self): db_map_item_metadata = { self._db_map: [ { - "id": 3, - "metadata_id": 5, "metadata_name": "author", "metadata_value": "Anonymous", "entity_id": 2, @@ -233,15 +219,15 @@ def test_add_relationship_metadata(self): self._assert_empty_last_row() def test_remove_object_metadata_row(self): - self._model.set_entity_ids({self._db_map: 1}) + self._model.set_entity_ids({self._db_map: self._db_map.get_entity_item(id=1)["id"]}) self._model.removeRows(0, 1) self.assertEqual(self._model.rowCount(), 1) def test_remove_object_parameter_value_metadata_row(self): - self._model.set_parameter_value_ids({self._db_map: 1}) + self._model.set_parameter_value_ids({self._db_map: self._db_map.get_parameter_value_item(id=1)["id"]}) self._model.removeRows(0, 1) self.assertEqual(self._model.rowCount(), 1) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py b/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py index 4e5af5323..05d1acf3b 100644 --- a/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py +++ b/tests/spine_db_editor/mvcmodels/test_metadata_table_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,20 +10,17 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the metadata table model. -""" +"""Unit tests for the metadata table model.""" import itertools from pathlib import Path from tempfile import TemporaryDirectory import unittest from unittest import mock -from PySide6.QtCore import QModelIndex, Qt +from PySide6.QtCore import Qt from PySide6.QtWidgets import QApplication -from spinetoolbox.helpers import signal_waiter from spinetoolbox.spine_db_editor.mvcmodels.metadata_table_model_base import Column from spinetoolbox.spine_db_editor.mvcmodels.metadata_table_model import MetadataTableModel -from ...mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager, fetch_model class TestMetadataTableModel(unittest.TestCase): @@ -39,12 +37,11 @@ def setUp(self): self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) QApplication.processEvents() self._model = MetadataTableModel(self._db_mngr, [self._db_map], None) - if self._model.canFetchMore(QModelIndex()): - self._model.fetchMore(QModelIndex()) + fetch_model(self._model) def tearDown(self): self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._model.deleteLater() @@ -104,14 +101,15 @@ def test_adding_data_to_another_database(self): try: db_map_2 = self._db_mngr.get_db_map(url, logger, codename="2nd database", create=True) self._model.set_db_maps([self._db_map, db_map_2]) - if self._model.canFetchMore(None): - self._model.fetchMore(None) + fetch_model(self._model) index = self._model.index(1, Column.DB_MAP) self.assertTrue(self._model.setData(index, "2nd database")) index = self._model.index(1, Column.NAME) self.assertTrue(self._model.setData(index, "title")) index = self._model.index(1, Column.VALUE) self.assertTrue(self._model.setData(index, "My precious.")) + while self._model.rowCount() != 3: + QApplication.processEvents() finally: self._db_mngr.close_session(url) self.assertEqual(self._model.rowCount(), 3) @@ -124,10 +122,10 @@ def test_adding_data_to_another_database(self): self.assertEqual(self._model.index(row, Column.DB_MAP).data(), "2nd database") def test_add_and_update_via_adding_entity_metadata(self): - db_map_data = {self._db_map: [{"name": "object class"}]} - self._db_mngr.add_object_classes(db_map_data) + db_map_data = {self._db_map: [{"name": "object class", "id": 1}]} + self._db_mngr.add_entity_classes(db_map_data) db_map_data = {self._db_map: [{"class_id": 1, "name": "object"}]} - self._db_mngr.add_objects(db_map_data) + self._db_mngr.add_entities(db_map_data) db_map_data = {self._db_map: [{"name": "author", "value": "Anonymous"}]} self._db_mngr.add_metadata(db_map_data) self.assertEqual(self._model.rowCount(), 2) @@ -141,7 +139,7 @@ def test_add_and_update_via_adding_entity_metadata(self): {"entity_name": "object", "metadata_name": "source", "metadata_value": "The Internet"}, ] } - self._db_mngr.add_entity_metadata(db_map_data) + self._db_mngr.add_ext_entity_metadata(db_map_data) self.assertEqual(self._model.rowCount(), 3) self.assertEqual(self._model.index(0, Column.NAME).data(), "author") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Anonymous") @@ -251,14 +249,11 @@ def test_batch_set_incomplete_data(self): def test_roll_back(self): db_map_data = {self._db_map: [{"name": "author", "value": "Anonymous"}]} self._db_mngr.add_metadata(db_map_data) - with signal_waiter(self._db_mngr.session_committed) as waiter: - self._db_mngr.commit_session("Add test data.", self._db_map) - waiter.wait() + self._db_mngr.commit_session("Add test data.", self._db_map) index = self._model.index(1, Column.NAME) self.assertTrue(self._model.setData(index, "title")) index = self._model.index(1, Column.VALUE) self.assertTrue(self._model.setData(index, "My precious.")) - self._db_mngr.add_metadata(db_map_data) self.assertEqual(self._model.rowCount(), 3) self.assertEqual(self._model.index(0, Column.NAME).data(), "author") self.assertEqual(self._model.index(0, Column.VALUE).data(), "Anonymous") @@ -267,10 +262,7 @@ def test_roll_back(self): self.assertEqual(self._model.index(1, Column.VALUE).data(), "My precious.") self.assertEqual(self._model.index(1, Column.DB_MAP).data(), "database") self._assert_empty_last_row() - with signal_waiter(self._db_mngr.session_rolled_back) as waiter: - self._db_mngr.rollback_session(self._db_map) - waiter.wait() - self._model.rollback([self._db_map]) + self._db_mngr.rollback_session(self._db_map) self._db_mngr.add_metadata(db_map_data) self.assertEqual(self._model.rowCount(), 2) self.assertEqual(self._model.index(0, Column.NAME).data(), "author") @@ -285,5 +277,5 @@ def _assert_empty_last_row(self): self.assertEqual(self._model.index(row, Column.DB_MAP).data(), "database") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_scenario_model.py b/tests/spine_db_editor/mvcmodels/test_scenario_model.py index 13c8b434e..09208434c 100644 --- a/tests/spine_db_editor/mvcmodels/test_scenario_model.py +++ b/tests/spine_db_editor/mvcmodels/test_scenario_model.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,16 +9,15 @@ # 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 . ###################################################################################################################### + """Unit tests for ``scenario_model`` module.""" -import pickle from pathlib import Path +import pickle from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock, patch - -from PySide6.QtCore import QMimeData, Qt +from PySide6.QtCore import QMimeData, Qt, QByteArray from PySide6.QtWidgets import QApplication - from spinetoolbox.helpers import signal_waiter from spinetoolbox.spine_db_editor.mvcmodels.scenario_model import ScenarioModel from spinetoolbox.spine_db_editor.mvcmodels import mime_types @@ -34,18 +34,21 @@ def setUpClass(cls): @staticmethod def _fetch_recursively(model): for item in model.visit_all(): - if item.can_fetch_more(): + while item.can_fetch_more(): item.fetch_more() + qApp.processEvents() class TestScenarioModel(_TestBase): + db_codename = "scenario_model_test_db" + def setUp(self): app_settings = MagicMock() logger = MagicMock() self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test_db"}) + self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": self.db_codename}) def tearDown(self): with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( @@ -53,7 +56,7 @@ def tearDown(self): ): self._db_editor.close() self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._db_editor.deleteLater() @@ -62,7 +65,7 @@ def test_initial_state(self): model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() data = model_data_to_dict(model) - expected = [[{"test_db": [["Type new scenario name here...", ""]]}, None]] + expected = [[{self.db_codename: [["Type new scenario name here...", ""]]}, None]] self.assertEqual(data, expected) def test_add_scenario(self): @@ -74,7 +77,7 @@ def test_add_scenario(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [{"scenario_1": [["Type scenario alternative name here...", ""]]}, "Just a test."], ["Type new scenario name here...", ""], ] @@ -88,7 +91,7 @@ def test_update_scenario(self): model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() self._fetch_recursively(model) - self._db_mngr.add_scenarios({self._db_map: [{"name": "scenario_1", "description": "Just a test."}]}) + self._db_mngr.add_scenarios({self._db_map: [{"name": "scenario_1", "description": "Just a test.", "id": 1}]}) self._db_mngr.update_scenarios( {self._db_map: [{"name": "scenario_2.0", "description": "More than just a test.", "id": 1}]} ) @@ -96,7 +99,7 @@ def test_update_scenario(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [{"scenario_2.0": [["Type scenario alternative name here...", ""]]}, "More than just a test."], ["Type new scenario name here...", ""], ] @@ -110,10 +113,10 @@ def test_remove_scenario(self): model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() self._fetch_recursively(model) - self._db_mngr.add_scenarios({self._db_map: [{"name": "scenario_1", "description": "Just a test."}]}) + self._db_mngr.add_scenarios({self._db_map: [{"name": "scenario_1", "description": "Just a test.", "id": 1}]}) self._db_mngr.remove_items({self._db_map: {"scenario": {1}}}) data = model_data_to_dict(model) - expected = [[{"test_db": [["Type new scenario name here...", ""]]}, None]] + expected = [[{self.db_codename: [["Type new scenario name here...", ""]]}, None]] self.assertEqual(data, expected) def test_mimeData(self): @@ -139,8 +142,9 @@ def test_mimeData(self): self.assertTrue(mime_data.hasText()) self.assertEqual(mime_data.text(), "Base\tBase alternative\r\n") self.assertTrue(mime_data.hasFormat(mime_types.ALTERNATIVE_DATA)) - data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA)) - self.assertEqual(data, {self._db_mngr.db_map_key(self._db_map): [1]}) + data = pickle.loads(mime_data.data(mime_types.ALTERNATIVE_DATA).data()) + id_ = self._db_map.get_alternative_item(id=1)["id"] + self.assertEqual(data, {self._db_mngr.db_map_key(self._db_map): [id_]}) def test_canDropMimeData_returns_true_when_dropping_alternative_to_empty_scenario(self): model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map) @@ -152,8 +156,8 @@ def test_canDropMimeData_returns_true_when_dropping_alternative_to_empty_scenari scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [1]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["Base"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) self.assertTrue(model.canDropMimeData(mime_data, Qt.DropAction.CopyAction, -1, -1, scenario_index)) def test_dropMimeData_adds_alternative_to_model(self): @@ -166,15 +170,15 @@ def test_dropMimeData_adds_alternative_to_model(self): scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [1]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["Base"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) self.assertTrue(model.dropMimeData(mime_data, Qt.DropAction.CopyAction, -1, -1, scenario_index)) self._fetch_recursively(model) model_data = model_data_to_dict(model) expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -203,15 +207,15 @@ def test_dropMimeData_reorders_alternatives(self): scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [1]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["Base"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) self.assertTrue(model.dropMimeData(mime_data, Qt.DropAction.CopyAction, -1, -1, scenario_index)) self._fetch_recursively(model) model_data = model_data_to_dict(model) expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -229,15 +233,15 @@ def test_dropMimeData_reorders_alternatives(self): ] self.assertEqual(model_data, expected) mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [2]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["alternative_1"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) self.assertTrue(model.dropMimeData(mime_data, Qt.DropAction.CopyAction, 0, 0, scenario_index)) self._fetch_recursively(model) model_data = model_data_to_dict(model) expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -255,16 +259,14 @@ def test_dropMimeData_reorders_alternatives(self): ] ] self.assertEqual(model_data, expected) - mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [1]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + mime_data = model.mimeData([model.index(1, 0, scenario_index)]) self.assertTrue(model.dropMimeData(mime_data, Qt.DropAction.CopyAction, 0, 0, scenario_index)) self._fetch_recursively(model) model_data = model_data_to_dict(model) expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -289,14 +291,14 @@ def test_paste_alternative_mime_data(self): model.build_tree() self._fetch_recursively(model) root_index = model.index(0, 0) - self.assertEqual(root_index.data(), "test_db") + self.assertEqual(root_index.data(), self.db_codename) edit_index = model.index(0, 0, root_index) model.setData(edit_index, "my_scenario", Qt.ItemDataRole.EditRole) scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [2]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["alternative_1"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) scenario_item = model.item_from_index(scenario_index) model.paste_alternative_mime_data(mime_data, -1, scenario_item) self._fetch_recursively(model) @@ -304,7 +306,7 @@ def test_paste_alternative_mime_data(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [{"my_scenario": [["alternative_1", ""], ["Type scenario alternative name here...", ""]]}, ""], ["Type new scenario name here...", ""], ] @@ -320,14 +322,14 @@ def test_paste_alternative_mime_data_ranks_alternatives(self): model.build_tree() self._fetch_recursively(model) root_index = model.index(0, 0) - self.assertEqual(root_index.data(), "test_db") + self.assertEqual(root_index.data(), self.db_codename) edit_index = model.index(0, 0, root_index) model.setData(edit_index, "my_scenario", Qt.ItemDataRole.EditRole) scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map): [1]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["Base"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) scenario_item = model.item_from_index(scenario_index) model.paste_alternative_mime_data(mime_data, -1, scenario_item) self._fetch_recursively(model) @@ -335,7 +337,7 @@ def test_paste_alternative_mime_data_ranks_alternatives(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -352,8 +354,8 @@ def test_paste_alternative_mime_data_ranks_alternatives(self): ] ] self.assertEqual(model_data, expected) - data = {self._db_mngr.db_map_key(self._db_map): [2]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map): ["alternative_1"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) scenario_item = model.item_from_index(scenario_index) model.paste_alternative_mime_data(mime_data, 0, scenario_item) self._fetch_recursively(model) @@ -361,7 +363,7 @@ def test_paste_alternative_mime_data_ranks_alternatives(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -381,8 +383,10 @@ def test_paste_alternative_mime_data_ranks_alternatives(self): self.assertEqual(model_data, expected) def test_duplicate_scenario(self): - self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1"}]}) - self._db_mngr.add_scenarios({self._db_map: [{"name": "my_scenario", "description": "My test scenario"}]}) + self._db_mngr.add_alternatives({self._db_map: [{"name": "alternative_1", "id": 2}]}) + self._db_mngr.add_scenarios( + {self._db_map: [{"name": "my_scenario", "description": "My test scenario", "id": 1}]} + ) self._db_mngr.set_scenario_alternatives({self._db_map: [{"id": 1, "alternative_id_list": [2, 1]}]}) model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map) model.build_tree() @@ -396,7 +400,7 @@ def test_duplicate_scenario(self): expected = [ [ { - "test_db": [ + self.db_codename: [ [ { "my_scenario": [ @@ -444,7 +448,7 @@ def tearDown(self): ): self._db_editor.close() self._db_mngr.close_all_sessions() - while not self._db_map1.connection.closed and self._db_map2.connection.closed: + while not self._db_map1.closed and self._db_map2.closed: QApplication.processEvents() self._db_mngr.clean_up() self._db_editor.deleteLater() @@ -462,8 +466,8 @@ def test_paste_alternative_mime_data_doesnt_paste_across_databases(self): scenario_index = model.index(0, 0, root_index) self.assertEqual(scenario_index.data(), "my_scenario") mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map1): [2]} - mime_data.setData(mime_types.ALTERNATIVE_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map1): ["alternative_1"]} + mime_data.setData(mime_types.ALTERNATIVE_DATA, QByteArray(pickle.dumps(data))) scenario_item = model.item_from_index(scenario_index) model.paste_alternative_mime_data(mime_data, -1, scenario_item) self._fetch_recursively(model) @@ -485,13 +489,16 @@ def test_paste_alternative_mime_data_doesnt_paste_across_databases(self): def test_paste_scenario_mime_data(self): self._db_mngr.add_scenarios({self._db_map1: [{"name": "my_scenario"}]}) self._db_mngr.add_alternatives({self._db_map1: [{"name": "alternative_1"}]}) - self._db_mngr.set_scenario_alternatives({self._db_map1: [{"id": 1, "alternative_id_list": [2, 1]}]}) + scenario_id = self._db_map1.get_scenario_item(name="my_scenario")["id"] + self._db_mngr.set_scenario_alternatives( + {self._db_map1: [{"id": scenario_id, "alternative_name_list": ["alternative_1", "Base"]}]} + ) model = ScenarioModel(self._db_editor, self._db_mngr, self._db_map1, self._db_map2) model.build_tree() self._fetch_recursively(model) mime_data = QMimeData() - data = {self._db_mngr.db_map_key(self._db_map1): [1]} - mime_data.setData(mime_types.SCENARIO_DATA, pickle.dumps(data)) + data = {self._db_mngr.db_map_key(self._db_map1): ["my_scenario"]} + mime_data.setData(mime_types.SCENARIO_DATA, QByteArray(pickle.dumps(data))) root_index = model.index(1, 0) self.assertEqual(root_index.data(), "test_db_2") db_item = model.item_from_index(root_index) @@ -539,5 +546,5 @@ def test_paste_scenario_mime_data(self): self.assertEqual(model_data, expected) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py index 267619bd7..095783da7 100644 --- a/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py +++ b/tests/spine_db_editor/mvcmodels/test_single_parameter_models.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,22 +9,26 @@ # 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 . ###################################################################################################################### + """Unit tests for the ``single_parameter_model`` module.""" import unittest from unittest.mock import MagicMock from PySide6.QtWidgets import QApplication - from spinedb_api import to_database from spinetoolbox.mvcmodels.shared import DB_MAP_ROLE -from spinetoolbox.spine_db_editor.mvcmodels.single_parameter_models import ( - SingleParameterModel, - SingleObjectParameterValueModel, +from spinetoolbox.spine_db_editor.mvcmodels.single_models import ( + SingleParameterDefinitionModel, + SingleParameterValueModel, +) +from spinetoolbox.spine_db_editor.mvcmodels.compound_models import ( + CompoundParameterDefinitionModel, + CompoundParameterValueModel, ) -from tests.mock_helpers import q_object, TestSpineDBManager +from tests.mock_helpers import q_object, TestSpineDBManager, fetch_model -OBJECT_PARAMETER_VALUE_HEADER = [ - "object_class_name", - "object_name", +ENTITY_PARAMETER_VALUE_HEADER = [ + "entity_class_name", + "entity_byname", "parameter_name", "alternative_name", "value", @@ -31,24 +36,43 @@ ] -class TestEmptySingleParameterModel(unittest.TestCase): +class TestSingleParameterDefinitionModel(SingleParameterDefinitionModel): + def __init__(self, db_mngr, db_map, entity_class_id, committed): + super().__init__(CompoundParameterDefinitionModel(None, db_mngr), db_map, entity_class_id, committed) + + +class TestSingleParameterValueModel(SingleParameterValueModel): + def __init__(self, db_mngr, db_map, entity_class_id, committed): + super().__init__(CompoundParameterValueModel(None, db_mngr), db_map, entity_class_id, committed) + + +class TestEmptySingleParameterDefinitionModel(unittest.TestCase): + HEADER = [ + "entity_class_name", + "parameter_name", + "list_value_name", + "default_value", + "description", + "database", + ] + @classmethod def setUpClass(cls): if not QApplication.instance(): QApplication() def test_rowCount_is_zero(self): - with q_object(SingleParameterModel(OBJECT_PARAMETER_VALUE_HEADER, None, None, None, False, False)) as model: + with q_object(TestSingleParameterDefinitionModel(None, None, 1, False)) as model: self.assertEqual(model.rowCount(), 0) def test_columnCount_is_header_length(self): - with q_object(SingleParameterModel(OBJECT_PARAMETER_VALUE_HEADER, None, None, None, False, False)) as model: - self.assertEqual(model.columnCount(), len(OBJECT_PARAMETER_VALUE_HEADER)) + with q_object(TestSingleParameterDefinitionModel(None, None, 1, False)) as model: + self.assertEqual(model.columnCount(), len(self.HEADER)) class TestSingleObjectParameterValueModel(unittest.TestCase): OBJECT_PARAMETER_VALUE_HEADER = [ - "object_class_name", + "entity_class_name", "object_name", "parameter_name", "alternative_name", @@ -72,9 +96,11 @@ def tearDown(self): self._db_mngr.deleteLater() def test_data_db_map_role(self): - self._db_mngr.add_object_classes({self._db_map: [{"name": "my_class"}]}) - self._db_mngr.add_parameter_definitions({self._db_map: [{"entity_class_id": 1, "name": "my_parameter"}]}) - self._db_mngr.add_objects({self._db_map: [{"class_id": 1, "name": "my_object"}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class", "id": 1}]}) + self._db_mngr.add_parameter_definitions( + {self._db_map: [{"entity_class_id": 1, "name": "my_parameter", "id": 1}]} + ) + self._db_mngr.add_entities({self._db_map: [{"class_id": 1, "name": "my_object", "id": 1}]}) value, type_ = to_database(2.3) self._db_mngr.add_parameter_values( { @@ -86,18 +112,16 @@ def test_data_db_map_role(self): "value": value, "type": type_, "alternative_id": 1, + "id": 1, } ] } ) - with q_object( - SingleObjectParameterValueModel(OBJECT_PARAMETER_VALUE_HEADER, self._db_mngr, self._db_map, 1, True, False) - ) as model: - if model.canFetchMore(None): - model.fetchMore(None) + with q_object(TestSingleParameterValueModel(self._db_mngr, self._db_map, 1, True)) as model: + fetch_model(model) model.add_rows([1]) self.assertEqual(model.index(0, 0).data(DB_MAP_ROLE), self._db_map) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_tree_item_utility.py b/tests/spine_db_editor/mvcmodels/test_tree_item_utility.py index 7c29ff074..41f2a90f3 100644 --- a/tests/spine_db_editor/mvcmodels/test_tree_item_utility.py +++ b/tests/spine_db_editor/mvcmodels/test_tree_item_utility.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,10 +9,10 @@ # 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 . ###################################################################################################################### + """Unit tests for the ``tree_item_utility`` module.""" from operator import attrgetter import unittest - from spinetoolbox.spine_db_editor.mvcmodels.tree_item_utility import SortChildrenMixin @@ -53,5 +54,5 @@ def test_insert_children_sorted_to_existing_list(self): self.assertEqual(sorter.child_ns(), [2, 3, 4, 6, 7, 9]) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/mvcmodels/test_utils.py b/tests/spine_db_editor/mvcmodels/test_utils.py index 9b78c2fc6..46a7fc2eb 100644 --- a/tests/spine_db_editor/mvcmodels/test_utils.py +++ b/tests/spine_db_editor/mvcmodels/test_utils.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,13 +9,12 @@ # 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 . ###################################################################################################################### + """Unit tests for the ``utils`` module.""" import unittest - from PySide6.QtCore import QObject from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import QApplication - from spinetoolbox.spine_db_editor.mvcmodels.utils import two_column_as_csv @@ -58,5 +58,5 @@ def test_indexes_from_single_column(self): self.assertEqual(as_csv, "12\r\n22\r\n") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/test_graphics_items.py b/tests/spine_db_editor/test_graphics_items.py index a91418a3b..35ddf1779 100644 --- a/tests/spine_db_editor/test_graphics_items.py +++ b/tests/spine_db_editor/test_graphics_items.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,19 +10,17 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for Database editor's ``graphics_items`` module. -""" +"""Unit tests for Database editor's ``graphics_items`` module.""" import unittest from unittest import mock from PySide6.QtCore import QPointF from PySide6.QtWidgets import QApplication -from spinetoolbox.spine_db_editor.graphics_items import RelationshipItem +from spinetoolbox.spine_db_editor.graphics_items import EntityItem from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from ..mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager -class TestRelationshipItem(unittest.TestCase): +class TestEntityItem(unittest.TestCase): _db_mngr = None @classmethod @@ -41,27 +40,24 @@ def setUp(self): self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) self._spine_db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "database"}) self._spine_db_editor.pivot_table_model = mock.MagicMock() - self._db_mngr.add_object_classes({self._db_map: [{"name": "oc", "id": 1}]}) - self._db_mngr.add_objects({self._db_map: [{"name": "o", "class_id": 1, "id": 1}]}) - self._db_mngr.add_relationship_classes( - {self._db_map: [{"name": "rc", "id": 2, "object_class_id_list": [1], "object_class_name_list": "oc"}]} - ) - self._db_mngr.add_relationships( + self._db_mngr.add_entity_classes({self._db_map: [{"name": "oc", "id": 1}]}) + self._db_mngr.add_entities({self._db_map: [{"name": "o", "class_id": 1, "id": 1}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "rc", "id": 2, "dimension_id_list": [1]}]}) + self._db_mngr.add_entities( { self._db_map: [ { "name": "r", "id": 2, "class_id": 2, - "class_name": "rc", - "object_id_list": [1], - "object_name_list": ["o"], + "entity_class_name": "rc", + "element_id_list": [1], } ] } ) - with mock.patch.object(RelationshipItem, "refresh_icon"): - self._item = RelationshipItem(self._spine_db_editor, 0.0, 0.0, 0, ((self._db_map, 2),)) + with mock.patch.object(EntityItem, "refresh_icon"): + self._item = EntityItem(self._spine_db_editor, 0.0, 0.0, 0, ((self._db_map, 2),)) @classmethod def tearDownClass(cls): @@ -78,17 +74,11 @@ def tearDown(self): self._spine_db_editor.deleteLater() self._spine_db_editor = None - def test_entity_type(self): - self.assertEqual(self._item.entity_type, "relationship") - - def test_entity_name(self): - self.assertEqual(self._item.entity_name, "r") - - def test_entity_class_type(self): - self.assertEqual(self._item.entity_class_type, "relationship_class") + def test_name(self): + self.assertEqual(self._item.name, "r") def test_entity_class_id(self): - self.assertEqual(self._item.entity_class_id(self._db_map), 2) + self.assertEqual(self._item.entity_class_id(self._db_map), self._db_map.get_entity_class_item(id=2)["id"]) def test_entity_class_name(self): self.assertEqual(self._item.entity_class_name, "rc") @@ -113,27 +103,30 @@ def test_db_maps(self): def test_db_map_data(self): self.assertEqual( - self._item.db_map_data(self._db_map), + self._item.db_map_data(self._db_map).resolve(), { - 'name': 'r', - 'id': 2, - 'class_id': 2, - 'class_name': 'rc', - 'object_id_list': (1,), - 'object_name_list': ['o'], - 'object_class_id_list': (1,), - 'commit_id': 2, + "name": "r", + "id": 2, + "class_id": 2, + "entity_class_name": "rc", + "element_id_list": (1,), + "description": None, }, ) def test_db_map_id_equals_entity_id(self): self.assertEqual(self._item.db_map_id(self._db_map), self._item.entity_id(self._db_map)) + def test_pos(self): + position = self._item.pos() + self.assertEqual(position.x(), 0.0) + self.assertEqual(position.y(), 0.0) + def test_add_arc_item(self): arc = mock.MagicMock() self._item.add_arc_item(arc) self.assertEqual(self._item.arc_items, [arc]) - arc.update_line.assert_called_once() + arc.update_line.assert_called() def test_apply_zoom(self): self._item.apply_zoom(0.5) @@ -144,9 +137,12 @@ def test_apply_zoom(self): def test_apply_rotation(self): arc = mock.MagicMock() self._item.add_arc_item(arc) - rotation_center = QPointF(100.0, 0.0) + position = self._item.pos() + self.assertEqual(position.x(), 1.0) + self.assertEqual(position.y(), 1.0) + rotation_center = QPointF(101.0, 1.0) self._item.apply_rotation(-90.0, rotation_center) - self.assertEqual(self._item.pos(), QPointF(100.0, -100.0)) + self.assertEqual(self._item.pos(), QPointF(101.0, -99.0)) arc.update_line.assert_has_calls([]) diff --git a/tests/spine_db_editor/test_helpers.py b/tests/spine_db_editor/test_helpers.py new file mode 100644 index 000000000..6729bf329 --- /dev/null +++ b/tests/spine_db_editor/test_helpers.py @@ -0,0 +1,34 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for Database editor's ``helpers`` module.""" +import unittest +from spinetoolbox.spine_db_editor.helpers import string_to_bool, string_to_display_icon + + +class TestStringToDisplayIcon(unittest.TestCase): + def test_converts_correctly(self): + self.assertEqual(string_to_display_icon("23"), 23) + self.assertIsNone(string_to_display_icon("")) + self.assertIsNone(string_to_display_icon("rubbish")) + + +class TestStringToBool(unittest.TestCase): + def test_converts_correctly(self): + self.assertTrue(string_to_bool("true")) + self.assertTrue(string_to_bool("TRUE")) + self.assertFalse(string_to_bool("false")) + self.assertFalse(string_to_bool("")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/__init__.py b/tests/spine_db_editor/widgets/__init__.py index 880dc471a..b0aeaa493 100644 --- a/tests/spine_db_editor/widgets/__init__.py +++ b/tests/spine_db_editor/widgets/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for Spine db editor's widgets. -""" +"""Unit tests for Spine db editor's widgets.""" diff --git a/tests/spine_db_editor/widgets/helpers.py b/tests/spine_db_editor/widgets/helpers.py index 772187384..b1a0634e4 100644 --- a/tests/spine_db_editor/widgets/helpers.py +++ b/tests/spine_db_editor/widgets/helpers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,19 +9,16 @@ # 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 . ###################################################################################################################### -"""Helper utilites for unit tests that test Database manager's table and tree views.""" + +"""Helper utilities for unit tests that test Database editor's table and tree views.""" from types import MethodType -import unittest from unittest import mock from PySide6.QtCore import QEvent, Qt from PySide6.QtGui import QKeyEvent from PySide6.QtWidgets import QApplication - -from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddObjectClassesDialog, AddObjectsDialog +from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddEntityClassesDialog, AddEntitiesDialog from spinetoolbox.helpers import signal_waiter -from spinetoolbox.widgets.custom_editors import SearchBarEditor -from ...mock_helpers import TestSpineDBManager +from spinetoolbox.spine_db_editor.widgets.custom_editors import SearchBarEditor class EditorDelegateMocking: @@ -106,53 +104,14 @@ def add_entity_tree_item(item_names, view, menu_action_text, dialog_class): add_items_dialog.accept() -def add_object_class(view, class_name): - add_entity_tree_item({0: class_name}, view, "Add object classes", AddObjectClassesDialog) +def add_zero_dimension_entity_class(view, name): + view._context_item = view.model().root_item + add_entity_tree_item({0: name}, view, "Add entity classes", AddEntityClassesDialog) -def add_object(view, object_name, object_class_index=0): +def add_entity(view, name, entity_class_index=0): model = view.model() root_index = model.index(0, 0) - class_index = model.index(object_class_index, 0, root_index) + class_index = model.index(entity_class_index, 0, root_index) view._context_item = model.item_from_index(class_index) - add_entity_tree_item({1: object_name}, view, "Add objects", AddObjectsDialog) - - -class TestBase(unittest.TestCase): - """Base class for Database editor's table and tree view tests.""" - - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def _common_setup(self, url, create): - with mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), mock.patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show" - ): - mock_settings = mock.MagicMock() - mock_settings.value.side_effect = lambda *args, **kwargs: 0 - self._db_mngr = TestSpineDBManager(mock_settings, None) - logger = mock.MagicMock() - self._db_map = self._db_mngr.get_db_map(url, logger, codename="database", create=create) - self._db_editor = SpineDBEditor(self._db_mngr, {url: "database"}) - QApplication.processEvents() - - def _common_tear_down(self): - with mock.patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state" - ), mock.patch("spinetoolbox.spine_db_manager.QMessageBox"): - self._db_editor.close() - self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - self._db_editor = None - - def _commit_changes_to_database(self, commit_message): - with mock.patch.object(self._db_editor, "_get_commit_msg") as commit_msg: - commit_msg.return_value = commit_message - with signal_waiter(self._db_mngr.session_committed) as waiter: - self._db_editor.ui.actionCommit.trigger() - waiter.wait() + add_entity_tree_item({0: name}, view, "Add entities", AddEntitiesDialog) diff --git a/tests/spine_db_editor/widgets/spine_db_editor_test_base.py b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py new file mode 100644 index 000000000..28b127d3b --- /dev/null +++ b/tests/spine_db_editor/widgets/spine_db_editor_test_base.py @@ -0,0 +1,218 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Base classes and helpers for database editor tests.""" +import unittest +from unittest import mock +from PySide6.QtWidgets import QApplication +from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor +from tests.mock_helpers import TestSpineDBManager + + +class DBEditorTestBase(unittest.TestCase): + @staticmethod + def _entity_class(*args): + return dict(zip(["id", "name", "dimension_id_list"], args)) + + @staticmethod + def _entity(*args): + return dict(zip(["id", "class_id", "name", "element_id_list"], args)) + + @staticmethod + def _parameter_definition(*args): + d = dict(zip(["id", "entity_class_id", "name"], args)) + d.update({"default_value": None, "default_type": None}) + return d + + @staticmethod + def _parameter_value(*args): + return dict( + zip( + ["id", "entity_class_id", "entity_id", "parameter_definition_id", "alternative_id", "value", "type"], + args, + ) + ) + + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + cls.create_mock_dataset() + + @classmethod + def create_mock_dataset(cls): + cls.fish_class = cls._entity_class(1, "fish") + cls.dog_class = cls._entity_class(2, "dog") + cls.fish_dog_class = cls._entity_class(3, "fish__dog", [cls.fish_class["id"], cls.dog_class["id"]]) + cls.dog_fish_class = cls._entity_class(4, "dog__fish", [cls.dog_class["id"], cls.fish_class["id"]]) + cls.nemo_object = cls._entity(1, cls.fish_class["id"], "nemo") + cls.pluto_object = cls._entity(2, cls.dog_class["id"], "pluto") + cls.scooby_object = cls._entity(3, cls.dog_class["id"], "scooby") + cls.pluto_nemo_rel = cls._entity( + 4, cls.dog_fish_class["id"], "dog__fish_pluto__nemo", [cls.pluto_object["id"], cls.nemo_object["id"]] + ) + cls.nemo_pluto_rel = cls._entity( + 5, cls.fish_dog_class["id"], "fish__dog_nemo__pluto", [cls.nemo_object["id"], cls.pluto_object["id"]] + ) + cls.nemo_scooby_rel = cls._entity( + 6, cls.fish_dog_class["id"], "fish__dog_nemo__scooby", [cls.nemo_object["id"], cls.scooby_object["id"]] + ) + cls.water_parameter = cls._parameter_definition(1, cls.fish_class["id"], "water") + cls.breed_parameter = cls._parameter_definition(2, cls.dog_class["id"], "breed") + cls.relative_speed_parameter = cls._parameter_definition(3, cls.fish_dog_class["id"], "relative_speed") + cls.combined_mojo_parameter = cls._parameter_definition(4, cls.dog_fish_class["id"], "combined_mojo") + cls.nemo_water = cls._parameter_value( + 1, + cls.water_parameter["entity_class_id"], + cls.nemo_object["id"], + cls.water_parameter["id"], + 1, + b'"salt"', + None, + ) + cls.pluto_breed = cls._parameter_value( + 2, + cls.breed_parameter["entity_class_id"], + cls.pluto_object["id"], + cls.breed_parameter["id"], + 1, + b'"bloodhound"', + None, + ) + cls.scooby_breed = cls._parameter_value( + 3, + cls.breed_parameter["entity_class_id"], + cls.scooby_object["id"], + cls.breed_parameter["id"], + 1, + b'"great dane"', + None, + ) + cls.nemo_pluto_relative_speed = cls._parameter_value( + 4, + cls.relative_speed_parameter["entity_class_id"], + cls.nemo_pluto_rel["id"], + cls.relative_speed_parameter["id"], + 1, + b"-1", + None, + ) + cls.nemo_scooby_relative_speed = cls._parameter_value( + 5, + cls.relative_speed_parameter["entity_class_id"], + cls.nemo_scooby_rel["id"], + cls.relative_speed_parameter["id"], + 1, + b"5", + None, + ) + cls.pluto_nemo_combined_mojo = cls._parameter_value( + 6, + cls.combined_mojo_parameter["entity_class_id"], + cls.pluto_nemo_rel["id"], + cls.combined_mojo_parameter["id"], + 1, + b"100", + None, + ) + + def setUp(self): + """Makes instances of SpineDBEditor classes.""" + with mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), mock.patch( + "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show" + ): + mock_settings = mock.Mock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self.db_mngr = TestSpineDBManager(mock_settings, None) + logger = mock.MagicMock() + self.mock_db_map = self.db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) + self.spine_db_editor = SpineDBEditor(self.db_mngr, {"sqlite://": "database"}) + self.spine_db_editor.pivot_table_model = mock.MagicMock() + self.spine_db_editor.entity_tree_model.hide_empty_classes = False + + def tearDown(self): + with mock.patch( + "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state" + ) as mock_save_w_s, mock.patch("spinetoolbox.spine_db_manager.QMessageBox"): + self.spine_db_editor.close() + mock_save_w_s.assert_called_once() + self.db_mngr.close_all_sessions() + while not self.mock_db_map.closed: + QApplication.processEvents() + self.db_mngr.clean_up() + self.spine_db_editor.deleteLater() + self.spine_db_editor = None + + def put_mock_object_classes_in_db_mngr(self): + """Puts fish and dog object classes in the db mngr.""" + object_classes = [self.fish_class, self.dog_class] + self.db_mngr.add_entity_classes({self.mock_db_map: object_classes}) + self.fetch_entity_tree_model() + + def put_mock_objects_in_db_mngr(self): + """Puts nemo, pluto and scooby objects in the db mngr.""" + objects = [self.nemo_object, self.pluto_object, self.scooby_object] + self.db_mngr.add_entities({self.mock_db_map: objects}) + self.fetch_entity_tree_model() + + def put_mock_relationship_classes_in_db_mngr(self): + """Puts dog__fish and fish__dog relationship classes in the db mngr.""" + relationship_classes = [self.fish_dog_class, self.dog_fish_class] + self.db_mngr.add_entity_classes({self.mock_db_map: relationship_classes}) + self.fetch_entity_tree_model() + + def put_mock_relationships_in_db_mngr(self): + """Puts pluto_nemo, nemo_pluto and nemo_scooby relationships in the db mngr.""" + relationships = [self.pluto_nemo_rel, self.nemo_pluto_rel, self.nemo_scooby_rel] + self.db_mngr.add_entities({self.mock_db_map: relationships}) + self.fetch_entity_tree_model() + + def put_mock_object_parameter_definitions_in_db_mngr(self): + """Puts water and breed object parameter definitions in the db mngr.""" + parameter_definitions = [self.water_parameter, self.breed_parameter] + self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + + def put_mock_relationship_parameter_definitions_in_db_mngr(self): + """Puts relative speed and combined mojo relationship parameter definitions in the db mngr.""" + parameter_definitions = [self.relative_speed_parameter, self.combined_mojo_parameter] + self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) + + def put_mock_object_parameter_values_in_db_mngr(self): + """Puts some object parameter values in the db mngr.""" + parameter_values = [self.nemo_water, self.pluto_breed, self.scooby_breed] + self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + + def put_mock_relationship_parameter_values_in_db_mngr(self): + """Puts some relationship parameter values in the db mngr.""" + parameter_values = [ + self.nemo_pluto_relative_speed, + self.nemo_scooby_relative_speed, + self.pluto_nemo_combined_mojo, + ] + self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) + + def put_mock_dataset_in_db_mngr(self): + """Puts mock dataset in the db mngr.""" + self.put_mock_object_classes_in_db_mngr() + self.put_mock_objects_in_db_mngr() + self.put_mock_relationship_classes_in_db_mngr() + self.put_mock_relationships_in_db_mngr() + self.put_mock_object_parameter_definitions_in_db_mngr() + self.put_mock_relationship_parameter_definitions_in_db_mngr() + self.put_mock_object_parameter_values_in_db_mngr() + self.put_mock_relationship_parameter_values_in_db_mngr() + + def fetch_entity_tree_model(self): + for item in self.spine_db_editor.entity_tree_model.visit_all(): + while item.can_fetch_more(): + item.fetch_more() + qApp.processEvents() diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditor.py b/tests/spine_db_editor/widgets/test_SpineDBEditor.py index 0b9bc817b..997a4ba5f 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditor.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,403 +10,119 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for SpineDBEditor classes. -""" - +"""Unit tests for SpineDBEditor classes.""" +import pathlib import unittest from unittest import mock from PySide6.QtWidgets import QApplication, QMessageBox from PySide6.QtCore import QModelIndex, QItemSelectionModel -import spinetoolbox.resources_icons_rc # pylint: disable=unused-import -from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from spinetoolbox.spine_db_editor.mvcmodels.compound_parameter_models import CompoundParameterModel -from .test_SpineDBEditorAdd import TestSpineDBEditorAddMixin -from .test_SpineDBEditorUpdate import TestSpineDBEditorUpdateMixin -from .test_SpineDBEditorRemove import TestSpineDBEditorRemoveMixin -from .test_SpineDBEditorFilter import TestSpineDBEditorFilterMixin -from ...mock_helpers import TestSpineDBManager - - -class TestSpineDBEditor( - TestSpineDBEditorAddMixin, - TestSpineDBEditorUpdateMixin, - TestSpineDBEditorRemoveMixin, - TestSpineDBEditorFilterMixin, - unittest.TestCase, -): - @staticmethod - def _object_class(*args): - return dict(zip(["id", "name", "description", "display_order", "display_icon"], args)) - - @staticmethod - def _object(*args): - return dict(zip(["id", "class_id", "class_name", "name", "description"], args)) - - @staticmethod - def _relationship_class(*args): - return dict(zip(["id", "name", "object_class_id_list", "object_class_name_list", "display_icon"], args)) - - @staticmethod - def _relationship(*args): - return dict( - zip( - [ - "id", - "class_id", - "name", - "class_name", - "object_class_id_list", - "object_class_name_list", - "object_id_list", - "object_name_list", - ], - args, - ) - ) - - @staticmethod - def _object_parameter_definition(*args): - d = dict(zip(["id", "object_class_id", "object_class_name", "name"], args)) - d.update({"default_value": None, "default_type": None}) - return d - - @staticmethod - def _relationship_parameter_definition(*args): - d = dict( - zip( - [ - "id", - "relationship_class_id", - "relationship_class_name", - "object_class_id_list", - "object_class_name_list", - "name", - ], - args, - ) - ) - d.update({"default_value": None, "default_type": None}) - return d - - @staticmethod - def _object_parameter_value(*args): - d = dict( - zip( - [ - "id", - "object_class_id", - "object_class_name", - "object_id", - "object_name", - "parameter_definition_id", - "parameter_name", - "alternative_id", - "value", - "type", - ], - args, - ) - ) - d["entity_id"] = d["object_id"] - return d - - @staticmethod - def _relationship_parameter_value(*args): - d = dict( - zip( - [ - "id", - "relationship_class_id", - "relationship_class_name", - "object_class_id_list", - "object_class_name_list", - "relationship_id", - "object_id_list", - "object_name_list", - "parameter_definition_id", - "parameter_name", - "alternative_id", - "value", - "type", - ], - args, - ) - ) - d["entity_id"] = d["relationship_id"] - return d - - @classmethod - def setUpClass(cls): - """Overridden method. Runs once before all tests in this class.""" - if not QApplication.instance(): - QApplication() - cls.create_mock_dataset() - - @classmethod - def create_mock_dataset(cls): - cls.fish_class = cls._object_class(1, "fish", "A fish.", 1, None) - cls.dog_class = cls._object_class(2, "dog", "A dog.", 3, None) - cls.fish_dog_class = cls._relationship_class( - 3, - "fish__dog", - [cls.fish_class["id"], cls.dog_class["id"]], - [cls.fish_class["name"], cls.dog_class["name"]], - None, - ) - cls.dog_fish_class = cls._relationship_class( - 4, - "dog__fish", - [cls.dog_class["id"], cls.fish_class["id"]], - [cls.dog_class["name"], cls.fish_class["name"]], - None, - ) - cls.nemo_object = cls._object(1, cls.fish_class["id"], cls.fish_class["name"], 'nemo', 'The lost one.') - cls.pluto_object = cls._object(2, cls.dog_class["id"], cls.dog_class["name"], 'pluto', "Mickey's.") - cls.scooby_object = cls._object(3, cls.dog_class["id"], cls.dog_class["name"], 'scooby', 'Scooby-Dooby-Doo.') - cls.pluto_nemo_rel = cls._relationship( - 4, - cls.dog_fish_class["id"], - "dog__fish_pluto__nemo", - cls.dog_fish_class["name"], - [cls.dog_class["id"], cls.fish_class["id"]], - [cls.dog_class["name"], cls.fish_class["name"]], - [cls.pluto_object["id"], cls.nemo_object["id"]], - [cls.pluto_object["name"], cls.nemo_object["name"]], - ) - cls.nemo_pluto_rel = cls._relationship( - 5, - cls.fish_dog_class["id"], - "fish__dog_nemo__pluto", - cls.fish_dog_class["name"], - [cls.fish_class["id"], cls.dog_class["id"]], - [cls.fish_class["name"], cls.dog_class["name"]], - [cls.nemo_object["id"], cls.pluto_object["id"]], - [cls.nemo_object["name"], cls.pluto_object["name"]], - ) - cls.nemo_scooby_rel = cls._relationship( - 6, - cls.fish_dog_class["id"], - "fish__dog_nemo__scooby", - cls.fish_dog_class["name"], - [cls.fish_class["id"], cls.dog_class["id"]], - [cls.fish_class["name"], cls.dog_class["name"]], - [cls.nemo_object["id"], cls.scooby_object["id"]], - [cls.nemo_object["name"], cls.scooby_object["name"]], - ) - cls.water_parameter = cls._object_parameter_definition(1, cls.fish_class["id"], cls.fish_class["name"], "water") - cls.breed_parameter = cls._object_parameter_definition(2, cls.dog_class["id"], cls.dog_class["name"], "breed") - cls.relative_speed_parameter = cls._relationship_parameter_definition( - 3, - cls.fish_dog_class["id"], - cls.fish_dog_class["name"], - cls.fish_dog_class["object_class_id_list"], - cls.fish_dog_class["object_class_name_list"], - "relative_speed", - ) - cls.combined_mojo_parameter = cls._relationship_parameter_definition( - 4, - cls.dog_fish_class["id"], - cls.dog_fish_class["name"], - cls.dog_fish_class["object_class_id_list"], - cls.dog_fish_class["object_class_name_list"], - "combined_mojo", - ) - cls.nemo_water = cls._object_parameter_value( - 1, - cls.water_parameter["object_class_id"], - cls.water_parameter["object_class_name"], - cls.nemo_object["id"], - cls.nemo_object["name"], - cls.water_parameter["id"], - cls.water_parameter["name"], - 1, - b'"salt"', - None, - ) - cls.pluto_breed = cls._object_parameter_value( - 2, - cls.breed_parameter["object_class_id"], - cls.breed_parameter["object_class_name"], - cls.pluto_object["id"], - cls.pluto_object["name"], - cls.breed_parameter["id"], - cls.breed_parameter["name"], - 1, - b'"bloodhound"', - None, - ) - cls.scooby_breed = cls._object_parameter_value( - 3, - cls.breed_parameter["object_class_id"], - cls.breed_parameter["object_class_name"], - cls.scooby_object["id"], - cls.scooby_object["name"], - cls.breed_parameter["id"], - cls.breed_parameter["name"], - 1, - b'"great dane"', - None, - ) - cls.nemo_pluto_relative_speed = cls._relationship_parameter_value( - 4, - cls.relative_speed_parameter["relationship_class_id"], - cls.relative_speed_parameter["relationship_class_name"], - cls.relative_speed_parameter["object_class_id_list"], - cls.relative_speed_parameter["object_class_name_list"], - cls.nemo_pluto_rel["id"], - cls.nemo_pluto_rel["object_id_list"], - cls.nemo_pluto_rel["object_name_list"], - cls.relative_speed_parameter["id"], - cls.relative_speed_parameter["name"], - 1, - b"-1", - None, - ) - cls.nemo_scooby_relative_speed = cls._relationship_parameter_value( - 5, - cls.relative_speed_parameter["relationship_class_id"], - cls.relative_speed_parameter["relationship_class_name"], - cls.relative_speed_parameter["object_class_id_list"], - cls.relative_speed_parameter["object_class_name_list"], - cls.nemo_scooby_rel["id"], - cls.nemo_scooby_rel["object_id_list"], - cls.nemo_scooby_rel["object_name_list"], - cls.relative_speed_parameter["id"], - cls.relative_speed_parameter["name"], - 1, - b"5", - None, - ) - cls.pluto_nemo_combined_mojo = cls._relationship_parameter_value( - 6, - cls.combined_mojo_parameter["relationship_class_id"], - cls.combined_mojo_parameter["relationship_class_name"], - cls.combined_mojo_parameter["object_class_id_list"], - cls.combined_mojo_parameter["object_class_name_list"], - cls.pluto_nemo_rel["id"], - cls.pluto_nemo_rel["object_id_list"], - cls.pluto_nemo_rel["object_name_list"], - cls.combined_mojo_parameter["id"], - cls.combined_mojo_parameter["name"], - 1, - b"100", - None, - ) - - def setUp(self): - """Overridden method. Runs before each test. Makes instances of SpineDBEditor classes.""" - with mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), mock.patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show" - ): - mock_settings = mock.Mock() - mock_settings.value.side_effect = lambda *args, **kwargs: 0 - self.db_mngr = TestSpineDBManager(mock_settings, None) - logger = mock.MagicMock() - self.mock_db_map = self.db_mngr.get_db_map("sqlite://", logger, codename="database", create=True) - self.spine_db_editor = SpineDBEditor(self.db_mngr, {"sqlite://": "database"}) - self.spine_db_editor.pivot_table_model = mock.MagicMock() - - def tearDown(self): - """Overridden method. Runs after each test. - Use this to free resources after a test if needed. - """ - with mock.patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state" - ) as mock_save_w_s, mock.patch("spinetoolbox.spine_db_manager.QMessageBox"): - self.spine_db_editor.close() - mock_save_w_s.assert_called_once() - self.db_mngr.close_all_sessions() - while not self.mock_db_map.connection.closed: - QApplication.processEvents() - self.db_mngr.clean_up() - self.spine_db_editor.deleteLater() - self.spine_db_editor = None - - def put_mock_object_classes_in_db_mngr(self): - """Put fish and dog object classes in the db mngr.""" - object_classes = [self.fish_class, self.dog_class] - self.db_mngr.add_object_classes({self.mock_db_map: object_classes}) - self.fetch_object_tree_model() - - def put_mock_objects_in_db_mngr(self): - """Put nemo, pluto and scooby objects in the db mngr.""" - objects = [self.nemo_object, self.pluto_object, self.scooby_object] - self.db_mngr.add_objects({self.mock_db_map: objects}) - self.fetch_object_tree_model() - - def put_mock_relationship_classes_in_db_mngr(self): - """Put dog__fish and fish__dog relationship classes in the db mngr.""" - relationship_classes = [self.fish_dog_class, self.dog_fish_class] - self.db_mngr.add_relationship_classes({self.mock_db_map: relationship_classes}) - self.fetch_object_tree_model() - - def put_mock_relationships_in_db_mngr(self): - """Put pluto_nemo, nemo_pluto and nemo_scooby relationships in the db mngr.""" - relationships = [self.pluto_nemo_rel, self.nemo_pluto_rel, self.nemo_scooby_rel] - self.db_mngr.add_relationships({self.mock_db_map: relationships}) - self.fetch_object_tree_model() - def put_mock_object_parameter_definitions_in_db_mngr(self): - """Put water and breed object parameter definitions in the db mngr.""" - parameter_definitions = [self.water_parameter, self.breed_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) - - def put_mock_relationship_parameter_definitions_in_db_mngr(self): - """Put relative speed and combined mojo relationship parameter definitions in the db mngr.""" - parameter_definitions = [self.relative_speed_parameter, self.combined_mojo_parameter] - self.db_mngr.add_parameter_definitions({self.mock_db_map: parameter_definitions}) - - def put_mock_object_parameter_values_in_db_mngr(self): - """Put some object parameter values in the db mngr.""" - parameter_values = [self.nemo_water, self.pluto_breed, self.scooby_breed] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) - - def put_mock_relationship_parameter_values_in_db_mngr(self): - """Put some relationship parameter values in the db mngr.""" - parameter_values = [ - self.nemo_pluto_relative_speed, - self.nemo_scooby_relative_speed, - self.pluto_nemo_combined_mojo, - ] - self.db_mngr.add_parameter_values({self.mock_db_map: parameter_values}) - - def put_mock_dataset_in_db_mngr(self): - """Put mock dataset in the db mngr.""" - self.put_mock_object_classes_in_db_mngr() - self.put_mock_objects_in_db_mngr() - self.put_mock_relationship_classes_in_db_mngr() - self.put_mock_relationships_in_db_mngr() - self.put_mock_object_parameter_definitions_in_db_mngr() - self.put_mock_relationship_parameter_definitions_in_db_mngr() - self.put_mock_object_parameter_values_in_db_mngr() - self.put_mock_relationship_parameter_values_in_db_mngr() +from spinedb_api import Duration +from spinedb_api.helpers import name_from_elements +from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor +from .spine_db_editor_test_base import DBEditorTestBase +from tests.mock_helpers import TestSpineDBManager - def fetch_object_tree_model(self): - for item in self.spine_db_editor.object_tree_model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() +class TestSpineDBEditor(DBEditorTestBase): def test_set_object_parameter_definition_defaults(self): """Test that defaults are set in object parameter_definition models according the object tree selection.""" self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() # Select fish item in object tree - root_item = self.spine_db_editor.object_tree_model.root_item + root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) - fish_index = self.spine_db_editor.object_tree_model.index_from_item(fish_item) - self.spine_db_editor.ui.treeView_object.setCurrentIndex(fish_index) - self.spine_db_editor.ui.treeView_object.selectionModel().select(fish_index, QItemSelectionModel.Select) + fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) + self.spine_db_editor.ui.treeView_entity.setCurrentIndex(fish_index) + self.spine_db_editor.ui.treeView_entity.selectionModel().select(fish_index, QItemSelectionModel.Select) # Check default in object parameter_definition - model = self.spine_db_editor.object_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model model.empty_model.fetchMore(QModelIndex()) h = model.header.index row_data = [] for row in range(model.rowCount()): - row_data.append(tuple(model.index(row, h(field)).data() for field in ("object_class_name", "database"))) + row_data.append(tuple(model.index(row, h(field)).data() for field in ("entity_class_name", "database"))) self.assertIn(("fish", "database"), row_data) + def test_save_window_state(self): + self.spine_db_editor.db_maps = [self.mock_db_map] + self.spine_db_editor.db_urls = [""] + self.spine_db_editor.save_window_state() + self.spine_db_editor.qsettings.beginGroup.assert_has_calls([mock.call("spineDBEditor"), mock.call("")]) + self.spine_db_editor.qsettings.endGroup.assert_has_calls([mock.call(), mock.call()]) + qsettings_save_calls = self.spine_db_editor.qsettings.setValue.call_args_list + self.assertEqual(len(qsettings_save_calls), 2) + saved_dict = {saved[0][0]: saved[0][1] for saved in qsettings_save_calls} + self.assertIn("windowState", saved_dict) + self.assertIn("last_open", saved_dict) + + def test_open_element_name_list_editor(self): + self.spine_db_editor.init_models() + self.put_mock_dataset_in_db_mngr() + self.fetch_entity_tree_model() + entity_model = self.spine_db_editor.entity_tree_model + entity_tree_root = entity_model.index(0, 0) + class_index = entity_model.index(3, 0, entity_tree_root) + self.assertEqual(class_index.data(), "fish__dog") + self.spine_db_editor.ui.treeView_entity.setCurrentIndex(class_index) + while self.spine_db_editor.parameter_value_model.rowCount() != 3: + QApplication.processEvents() + model = self.spine_db_editor.parameter_value_model + index = model.index(0, 1) + with mock.patch( + "spinetoolbox.spine_db_editor.widgets.stacked_view_mixin.ElementNameListEditor" + ) as editor_constructor: + editor = mock.MagicMock() + editor_constructor.return_value = editor + self.spine_db_editor.show_element_name_list_editor(index, self.fish_dog_class["id"], self.mock_db_map) + editor_constructor.assert_called_with( + self.spine_db_editor, + index, + ["fish", "dog"], + [[("nemo",)], [("pluto",), ("scooby",)]], + (("nemo",), ("pluto",)), + ) + editor.show.assert_called_once() + + def test_import_spineopt_basic_model_template(self): + self.spine_db_editor.init_models() + resource_path = pathlib.Path(__file__).parent.parent.parent / "test_resources" + template_path = resource_path / "spineopt_template.json" + self.spine_db_editor.import_from_json(str(template_path)) + model_path = resource_path / "basic_model_template.json" + self.spine_db_editor.import_from_json(str(model_path)) + expected_entities = { + "model": {"simple"}, + "report": {"report1"}, + "stochastic_scenario": {"realization"}, + "stochastic_structure": {"deterministic"}, + "temporal_block": {"flat"}, + "model__default_stochastic_structure": {name_from_elements(("simple", "deterministic"))}, + "model__default_temporal_block": {name_from_elements(("simple", "flat"))}, + "model__report": {name_from_elements(("simple", "report1"))}, + "stochastic_structure__stochastic_scenario": {name_from_elements(("deterministic", "realization"))}, + } + for class_name, expected_names in expected_entities.items(): + with self.subTest(entity_class=class_name): + entities = self.mock_db_map.get_entity_items(entity_class_name=class_name) + self.assertEqual(len(entities), len(expected_names)) + names = {entity["name"] for entity in entities} + self.assertEqual(names, expected_names) + expected_parameter_values = {("temporal_block", "flat", "resolution", "Base"): Duration("1D")} + for unique_id, expected_value in expected_parameter_values.items(): + class_name, entity_name, definition_name, alternative_name = unique_id + with self.subTest( + entity_class=class_name, entity=entity_name, parameter=definition_name, alternative=alternative_name + ): + value = self.mock_db_map.get_parameter_value_item( + entity_class_name=class_name, + entity_byname=(entity_name,), + parameter_definition_name=definition_name, + alternative_name=alternative_name, + ) + self.assertEqual(value["parsed_value"], expected_value) + class TestClosingDBEditors(unittest.TestCase): @classmethod @@ -426,7 +143,7 @@ def setUp(self): def tearDown(self): self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() for editor in self._editors: @@ -440,7 +157,7 @@ def _make_db_editor(self): def test_first_editor_to_close_does_not_ask_for_confirmation_on_dirty_database(self): editor_1 = self._make_db_editor() editor_2 = self._make_db_editor() - self._db_mngr.add_object_classes({self._db_map: [{"name": "my_object_class"}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_object_class"}]}) self.assertTrue(self._db_mngr.dirty(self._db_map)) with mock.patch( "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state" @@ -455,7 +172,7 @@ def test_first_editor_to_close_does_not_ask_for_confirmation_on_dirty_database(s def test_editor_asks_for_confirmation_even_when_non_editor_listeners_are_connected(self): editor = self._make_db_editor() - self._db_mngr.add_object_classes({self._db_map: [{"name": "my_object_class"}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_object_class"}]}) self.assertTrue(self._db_mngr.dirty(self._db_map)) non_editor_listener = object() self._db_mngr.register_listener(non_editor_listener, self._db_map) @@ -469,5 +186,5 @@ def test_editor_asks_for_confirmation_even_when_non_editor_listeners_are_connect commit_changes.assert_called_once() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py index 89c9d5279..6686a4e25 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorAdd.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,65 +10,62 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TreeViewFormAddMixin class. -""" +"""Unit tests for adding database items in Database editor.""" from unittest import mock from spinetoolbox.helpers import DB_ITEM_SEPARATOR -from spinetoolbox.spine_db_editor.mvcmodels.single_parameter_models import SingleParameterModel +from spinetoolbox.spine_db_editor.mvcmodels.single_models import SingleParameterDefinitionModel +from .spine_db_editor_test_base import DBEditorTestBase -class TestSpineDBEditorAddMixin: - def test_add_object_classes_to_object_tree_model(self): +class TestSpineDBEditorAdd(DBEditorTestBase): + def test_add_entity_classes_to_object_tree_model(self): """Test that object classes are added to the object tree model.""" - root_item = self.spine_db_editor.object_tree_model.root_item + root_item = self.spine_db_editor.entity_tree_model.root_item self.put_mock_object_classes_in_db_mngr() - self.fetch_object_tree_model() - dog_item, fish_item = root_item.children - self.assertEqual(fish_item.item_type, "object_class") + self.fetch_entity_tree_model() + dog_item = next(x for x in root_item.children if x.display_data == "dog") + fish_item = next(x for x in root_item.children if x.display_data == "fish") + self.assertEqual(fish_item.item_type, "entity_class") self.assertEqual(fish_item.display_data, "fish") - self.assertEqual(dog_item.item_type, "object_class") + self.assertEqual(dog_item.item_type, "entity_class") self.assertEqual(dog_item.display_data, "dog") self.assertEqual(root_item.child_count(), 2) - def test_add_objects_to_object_tree_model(self): + def test_add_entities_to_object_tree_model(self): """Test that objects are added to the object tree model.""" self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item, fish_item = root_item.children + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item + dog_item = next(x for x in root_item.children if x.display_data == "dog") + fish_item = next(x for x in root_item.children if x.display_data == "fish") nemo_item = fish_item.child(0) pluto_item, scooby_item = dog_item.children - self.assertEqual(nemo_item.item_type, "object") + self.assertEqual(nemo_item.item_type, "entity") self.assertEqual(nemo_item.display_data, "nemo") self.assertEqual(fish_item.child_count(), 1) - self.assertEqual(pluto_item.item_type, "object") + self.assertEqual(pluto_item.item_type, "entity") self.assertEqual(pluto_item.display_data, "pluto") - self.assertEqual(scooby_item.item_type, "object") + self.assertEqual(scooby_item.item_type, "entity") self.assertEqual(scooby_item.display_data, "scooby") self.assertEqual(dog_item.child_count(), 2) def test_add_relationship_classes_to_object_tree_model(self): - """Test that relationship classes are added to the object tree model.""" + """Test that entity classes are added to the object tree model.""" self.spine_db_editor.init_models() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item, fish_item = root_item.children - nemo_item = fish_item.child(0) - pluto_item = dog_item.child(0) - nemo_dog_fish_item = nemo_item.child(0) - pluto_fish_dog_item = pluto_item.child(1) - self.assertEqual(nemo_dog_fish_item.item_type, "relationship_class") - self.assertEqual(nemo_dog_fish_item.display_data, "dog__fish") - self.assertEqual(nemo_item.child_count(), 2) - self.assertEqual(pluto_fish_dog_item.item_type, "relationship_class") - self.assertEqual(pluto_fish_dog_item.display_data, "fish__dog") - self.assertEqual(pluto_item.child_count(), 2) + root_item = self.spine_db_editor.entity_tree_model.root_item + dog_fish_item = next(x for x in root_item.children if x.display_data == "dog__fish") + fish_dog_item = next(x for x in root_item.children if x.display_data == "fish__dog") + self.assertEqual(root_item.child_count(), 4) + self.assertEqual(dog_fish_item.item_type, "entity_class") + self.assertEqual(dog_fish_item.display_data, "dog__fish") + self.assertEqual(fish_dog_item.item_type, "entity_class") + self.assertEqual(fish_dog_item.display_data, "fish__dog") def test_add_relationships_to_object_tree_model(self): """Test that relationships are added to the object tree model.""" @@ -76,85 +74,80 @@ def test_add_relationships_to_object_tree_model(self): self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() self.put_mock_relationships_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item, fish_item = root_item.children + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item + dog_item = next(x for x in root_item.children if x.display_data == "dog") + fish_item = next(x for x in root_item.children if x.display_data == "fish") nemo_item = fish_item.child(0) pluto_item, scooby_item = dog_item.children - nemo_dog_fish_item, nemo_fish_dog_item = nemo_item.children - pluto_fish_dog_item, pluto_dog_fish_item = pluto_item.children - scooby_dog_fish_item, scooby_fish_dog_item = scooby_item.children - pluto_nemo_item1 = pluto_dog_fish_item.child(0) - pluto_nemo_item2 = nemo_dog_fish_item.child(0) - nemo_pluto_item1 = pluto_fish_dog_item.child(0) - nemo_pluto_item2 = nemo_fish_dog_item.child(0) - nemo_scooby_item1 = scooby_fish_dog_item.child(0) - nemo_scooby_item2 = nemo_fish_dog_item.child(1) - self.assertEqual(nemo_dog_fish_item.child_count(), 1) - self.assertEqual(nemo_fish_dog_item.child_count(), 2) - self.assertEqual(pluto_dog_fish_item.child_count(), 1) - self.assertEqual(pluto_fish_dog_item.child_count(), 1) - self.assertEqual(scooby_dog_fish_item.child_count(), 0) - self.assertEqual(scooby_fish_dog_item.child_count(), 1) - self.assertEqual(pluto_nemo_item1.item_type, "relationship") - self.assertEqual(pluto_nemo_item1.display_data, 'nemo') - self.assertEqual(pluto_nemo_item2.item_type, "relationship") - self.assertEqual(pluto_nemo_item2.display_data, 'pluto') - self.assertEqual(nemo_pluto_item1.item_type, "relationship") - self.assertEqual(nemo_pluto_item1.display_data, 'nemo') - self.assertEqual(nemo_pluto_item2.item_type, "relationship") - self.assertEqual(nemo_pluto_item2.display_data, 'pluto') - self.assertEqual(nemo_scooby_item1.item_type, "relationship") - self.assertEqual(nemo_scooby_item1.display_data, 'nemo') - self.assertEqual(nemo_scooby_item2.item_type, "relationship") - self.assertEqual(nemo_scooby_item2.display_data, 'scooby') + pluto_nemo_item1 = pluto_item.child(0) + pluto_nemo_item2 = nemo_item.child(0) + nemo_pluto_item1 = pluto_item.child(1) + nemo_pluto_item2 = nemo_item.child(1) + nemo_scooby_item1 = scooby_item.child(0) + nemo_scooby_item2 = nemo_item.child(2) + self.assertEqual(nemo_item.child_count(), 3) + self.assertEqual(pluto_item.child_count(), 2) + self.assertEqual(scooby_item.child_count(), 1) + self.assertEqual(pluto_nemo_item1.item_type, "entity") + self.assertTrue("dog__fish" in pluto_nemo_item1.display_id and "nemo" in pluto_nemo_item1.display_data) + self.assertEqual(pluto_nemo_item2.item_type, "entity") + self.assertTrue("dog__fish" in pluto_nemo_item2.display_id and "pluto" in pluto_nemo_item2.display_data) + self.assertEqual(nemo_pluto_item1.item_type, "entity") + self.assertTrue("fish__dog" in nemo_pluto_item1.display_id and "nemo" in nemo_pluto_item1.display_data) + self.assertEqual(nemo_pluto_item2.item_type, "entity") + self.assertTrue("fish__dog" in nemo_pluto_item2.display_id and "pluto" in nemo_pluto_item2.display_data) + self.assertEqual(nemo_scooby_item1.item_type, "entity") + self.assertTrue("fish__dog" in nemo_scooby_item1.display_id and "nemo" in nemo_scooby_item1.display_data) + self.assertEqual(nemo_scooby_item2.item_type, "entity") + self.assertTrue("fish__dog" in nemo_scooby_item2.display_id and "scooby" in nemo_scooby_item2.display_data) def test_add_object_parameter_definitions_to_model(self): """Test that object parameter definitions are added to the model.""" - model = self.spine_db_editor.object_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() - with mock.patch.object(SingleParameterModel, "__lt__") as lt_mocked: + with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_definitions_in_db_mngr() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("object_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("fish", "water") in parameters) self.assertTrue(("dog", "breed") in parameters) def test_add_relationship_parameter_definitions_to_model(self): - """Test that relationship parameter definitions are added to the model.""" - model = self.spine_db_editor.relationship_parameter_definition_model + """Test that entity parameter definitions are added to the model.""" + model = self.spine_db_editor.parameter_definition_model if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() - with mock.patch.object(SingleParameterModel, "__lt__") as lt_mocked: + with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_definitions_in_db_mngr() h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("relationship_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("fish__dog", "relative_speed") in parameters) self.assertTrue(("dog__fish", "combined_mojo") in parameters) def test_add_object_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" - model = self.spine_db_editor.object_parameter_value_model + model = self.spine_db_editor.parameter_value_model if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() - with mock.patch.object(SingleParameterModel, "__lt__") as lt_mocked: + with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_object_parameter_values_in_db_mngr() h = model.header.index @@ -162,7 +155,7 @@ def test_add_object_parameter_values_to_model(self): for row in range(model.rowCount()): parameters.append( ( - model.index(row, h("object_name")).data(), + model.index(row, h("entity_byname")).data(), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) @@ -173,7 +166,7 @@ def test_add_object_parameter_values_to_model(self): def test_add_relationship_parameter_values_to_model(self): """Test that object parameter values are added to the model.""" - model = self.spine_db_editor.relationship_parameter_value_model + model = self.spine_db_editor.parameter_value_model if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() @@ -182,7 +175,7 @@ def test_add_relationship_parameter_values_to_model(self): self.put_mock_relationships_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_relationship_parameter_definitions_in_db_mngr() - with mock.patch.object(SingleParameterModel, "__lt__") as lt_mocked: + with mock.patch.object(SingleParameterDefinitionModel, "__lt__") as lt_mocked: lt_mocked.return_value = False self.put_mock_relationship_parameter_values_in_db_mngr() h = model.header.index @@ -190,7 +183,7 @@ def test_add_relationship_parameter_values_to_model(self): for row in range(model.rowCount()): parameters.append( ( - tuple((model.index(row, h("object_name_list")).data() or "").split(DB_ITEM_SEPARATOR)), + tuple((model.index(row, h("entity_byname")).data() or "").split(DB_ITEM_SEPARATOR)), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py b/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py index 034504663..21c075489 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorBase.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,12 @@ # this program. If not, see . ###################################################################################################################### -""" -Contains unit tests for the SpineDBEditorBase class. -""" - +"""Contains unit tests for the SpineDBEditorBase class.""" import unittest from unittest import mock from PySide6.QtWidgets import QApplication from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditorBase -from ...mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager class TestSpineDBEditorBase(unittest.TestCase): @@ -56,15 +54,30 @@ def tearDown(self): self.db_editor.deleteLater() self.db_editor = None - def test_save_window_state(self): - self.db_editor.save_window_state() - self.db_editor.qsettings.beginGroup.assert_has_calls([mock.call("spineDBEditor"), mock.call("")]) - self.db_editor.qsettings.endGroup.assert_has_calls([mock.call(), mock.call()]) - qsettings_save_calls = self.db_editor.qsettings.setValue.call_args_list - self.assertEqual(len(qsettings_save_calls), 1) - saved_dict = {saved[0][0]: saved[0][1] for saved in qsettings_save_calls} - self.assertIn("windowState", saved_dict) + def test_import_file_recognizes_excel(self): + with mock.patch.object(self.db_editor, "qsettings"), mock.patch.object( + self.db_editor, "import_from_excel" + ) as mock_import_from_excel, mock.patch("spinetoolbox.helpers.QFileDialog") as mock_file_dialog: + mock_file_dialog.getOpenFileName.return_value = "my_excel_file.xlsx", "Excel files (*.xlsx)" + self.db_editor.import_file() + mock_import_from_excel.assert_called_once_with("my_excel_file.xlsx") + + def test_import_file_recognizes_sqlite(self): + with mock.patch.object(self.db_editor, "qsettings"), mock.patch.object( + self.db_editor, "import_from_sqlite" + ) as mock_import_from_sqlite, mock.patch("spinetoolbox.helpers.QFileDialog") as mock_file_dialog: + mock_file_dialog.getOpenFileName.return_value = "my_sqlite_file.sqlite", "SQLite files (*.sqlite)" + self.db_editor.import_file() + mock_import_from_sqlite.assert_called_once_with("my_sqlite_file.sqlite") + + def test_import_file_recognizes_json(self): + with mock.patch.object(self.db_editor, "qsettings"), mock.patch.object( + self.db_editor, "import_from_json" + ) as mock_import_from_json, mock.patch("spinetoolbox.helpers.QFileDialog") as mock_file_dialog: + mock_file_dialog.getOpenFileName.return_value = "my_json_file.json", "JSON files (*.json)" + self.db_editor.import_file() + mock_import_from_json.assert_called_once_with("my_json_file.json") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py index f47169aca..4b88016bf 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorFilter.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,31 +10,22 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TreeViewFormFilterMixin class. -""" - +"""Unit tests for filtering in Database editor.""" from PySide6.QtCore import Qt, QItemSelectionModel from spinetoolbox.helpers import DB_ITEM_SEPARATOR +from .spine_db_editor_test_base import DBEditorTestBase -class TestSpineDBEditorFilterMixin: +class TestSpineDBEditorFilter(DBEditorTestBase): @property def _parameter_models(self): - return ( - self.spine_db_editor.object_parameter_definition_model, - self.spine_db_editor.object_parameter_value_model, - self.spine_db_editor.relationship_parameter_definition_model, - self.spine_db_editor.relationship_parameter_value_model, - ) + return (self.spine_db_editor.parameter_definition_model, self.spine_db_editor.parameter_value_model) @property def _filtered_fields(self): return { - self.spine_db_editor.object_parameter_definition_model: ("object_class_name",), - self.spine_db_editor.object_parameter_value_model: ("object_class_name", "object_name"), - self.spine_db_editor.relationship_parameter_definition_model: ("relationship_class_name",), - self.spine_db_editor.relationship_parameter_value_model: ("relationship_class_name", "object_name_list"), + self.spine_db_editor.parameter_definition_model: ("entity_class_name",), + self.spine_db_editor.parameter_value_model: ("entity_class_name", "entity_byname"), } @staticmethod @@ -48,100 +40,101 @@ def _assert_filter(self, filtered_values): fields = self._filtered_fields[model] data = self._parameter_data(model, *fields) values = filtered_values[model] + unfiltered_count = len(data) self.assertTrue(all(value in data for value in values)) model.refresh() data = self._parameter_data(model, *fields) + filtered_count = len(data) self.assertTrue(all(value not in data for value in values)) + # Check that only the items that were supposed to be filtered were actually filtered. + self.assertEqual(filtered_count, unfiltered_count - len(values)) - def test_filter_parameter_tables_per_object_class(self): + def test_filter_parameter_tables_per_zero_dimensional_entity_class(self): """Test that parameter tables are filtered when selecting object classes in the object tree.""" for model in self._filtered_fields: if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - root_item = self.spine_db_editor.object_tree_model.root_item - fish_item = root_item.child(1) - fish_index = self.spine_db_editor.object_tree_model.index_from_item(fish_item) - selection_model = self.spine_db_editor.ui.treeView_object.selectionModel() + root_item = self.spine_db_editor.entity_tree_model.root_item + fish_item = root_item.child(2) + fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) + selection_model = self.spine_db_editor.ui.treeView_entity.selectionModel() selection_model.setCurrentIndex(fish_index, QItemSelectionModel.NoUpdate) selection_model.select(fish_index, QItemSelectionModel.Select) filtered_values = { - self.spine_db_editor.object_parameter_definition_model: [('dog',)], - self.spine_db_editor.object_parameter_value_model: [('dog', 'pluto'), ('dog', 'scooby')], - self.spine_db_editor.relationship_parameter_definition_model: [], - self.spine_db_editor.relationship_parameter_value_model: [], + self.spine_db_editor.parameter_definition_model: [("dog",)], + self.spine_db_editor.parameter_value_model: [("dog", "pluto"), ("dog", "scooby")], } self._assert_filter(filtered_values) - def test_filter_parameter_tables_per_object(self): - """Test that parameter tables are filtered when selecting objects in the object tree.""" + def test_filter_parameter_tables_per_nonzero_dimensional_entity_class(self): + """Test that parameter tables are filtered when selecting relationship classes in the object tree.""" for model in self._filtered_fields: if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item = root_item.child(0) - pluto_item = dog_item.child(0) - pluto_index = self.spine_db_editor.object_tree_model.index_from_item(pluto_item) - selection_model = self.spine_db_editor.ui.treeView_object.selectionModel() - selection_model.setCurrentIndex(pluto_index, QItemSelectionModel.NoUpdate) - selection_model.select(pluto_index, QItemSelectionModel.Select) + root_item = self.spine_db_editor.entity_tree_model.root_item + fish_dog_item = root_item.child(3) + fish_dog_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_dog_item) + selection_model = self.spine_db_editor.ui.treeView_entity.selectionModel() + selection_model.setCurrentIndex(fish_dog_index, QItemSelectionModel.NoUpdate) + selection_model.select(fish_dog_index, QItemSelectionModel.Select) filtered_values = { - self.spine_db_editor.object_parameter_definition_model: [('fish',)], - self.spine_db_editor.object_parameter_value_model: [('fish', 'nemo'), ('dog', 'scooby')], - self.spine_db_editor.relationship_parameter_definition_model: [], - self.spine_db_editor.relationship_parameter_value_model: [ - ('fish__dog', DB_ITEM_SEPARATOR.join(['nemo', 'scooby'])) + self.spine_db_editor.parameter_definition_model: [("dog",), ("fish",), ("dog__fish",)], + self.spine_db_editor.parameter_value_model: [ + ("dog__fish", DB_ITEM_SEPARATOR.join(["pluto", "nemo"])), + ("fish", "nemo"), + ("dog", "pluto"), + ("dog", "scooby"), ], } self._assert_filter(filtered_values) - def test_filter_parameter_tables_per_relationship_class(self): - """Test that parameter tables are filtered when selecting relationship classes in the object tree.""" + def test_filter_parameter_tables_per_entity_class_and_entity_cross_selection(self): for model in self._filtered_fields: if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - root_item = self.spine_db_editor.object_tree_model.root_item + root_item = self.spine_db_editor.entity_tree_model.root_item + fish_item = root_item.child(2) + fish_index = self.spine_db_editor.entity_tree_model.index_from_item(fish_item) + selection_model = self.spine_db_editor.ui.treeView_entity.selectionModel() dog_item = root_item.child(0) - pluto_item = dog_item.child(0) - pluto_fish_dog_item = pluto_item.child(1) - pluto_fish_dog_index = self.spine_db_editor.object_tree_model.index_from_item(pluto_fish_dog_item) - selection_model = self.spine_db_editor.ui.treeView_object.selectionModel() - selection_model.setCurrentIndex(pluto_fish_dog_index, QItemSelectionModel.NoUpdate) - selection_model.select(pluto_fish_dog_index, QItemSelectionModel.Select) + scooby_item = dog_item.child(1) + scooby_index = self.spine_db_editor.entity_tree_model.index_from_item(scooby_item) + selection_model.setCurrentIndex(fish_index, QItemSelectionModel.NoUpdate) + selection_model.select(fish_index, QItemSelectionModel.Select) + selection_model.setCurrentIndex(scooby_index, QItemSelectionModel.NoUpdate) + selection_model.select(scooby_index, QItemSelectionModel.Select) filtered_values = { - self.spine_db_editor.object_parameter_definition_model: [], - self.spine_db_editor.object_parameter_value_model: [], - self.spine_db_editor.relationship_parameter_definition_model: [('dog__fish',)], - self.spine_db_editor.relationship_parameter_value_model: [ - ('dog__fish', DB_ITEM_SEPARATOR.join(['pluto', 'nemo'])) + self.spine_db_editor.parameter_definition_model: [], + self.spine_db_editor.parameter_value_model: [ + ("dog", "pluto"), + ("fish__dog", "nemo ǀ pluto"), + ("dog__fish", "pluto ǀ nemo"), ], } self._assert_filter(filtered_values) - def test_filter_parameter_tables_per_relationship(self): - """Test that parameter tables are filtered when selecting relationships in the object tree.""" + def test_filter_parameter_tables_per_entity(self): + """Test that parameter tables are filtered when selecting objects in the object tree.""" for model in self._filtered_fields: if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - root_item = self.spine_db_editor.object_tree_model.root_item + root_item = self.spine_db_editor.entity_tree_model.root_item dog_item = root_item.child(0) pluto_item = dog_item.child(0) - pluto_fish_dog_item = pluto_item.child(1) - fish_dog_nemo_pluto_item = pluto_fish_dog_item.child(0) - fish_dog_nemo_pluto_index = self.spine_db_editor.object_tree_model.index_from_item(fish_dog_nemo_pluto_item) - selection_model = self.spine_db_editor.ui.treeView_object.selectionModel() - selection_model.setCurrentIndex(fish_dog_nemo_pluto_index, QItemSelectionModel.NoUpdate) - selection_model.select(fish_dog_nemo_pluto_index, QItemSelectionModel.Select) + pluto_index = self.spine_db_editor.entity_tree_model.index_from_item(pluto_item) + selection_model = self.spine_db_editor.ui.treeView_entity.selectionModel() + selection_model.setCurrentIndex(pluto_index, QItemSelectionModel.NoUpdate) + selection_model.select(pluto_index, QItemSelectionModel.Select) filtered_values = { - self.spine_db_editor.object_parameter_definition_model: [], - self.spine_db_editor.object_parameter_value_model: [], - self.spine_db_editor.relationship_parameter_definition_model: [('dog__fish',)], - self.spine_db_editor.relationship_parameter_value_model: [ - ('fish__dog', DB_ITEM_SEPARATOR.join(['nemo', 'scooby'])), - ('dog__fish', DB_ITEM_SEPARATOR.join(['pluto', 'nemo'])), + self.spine_db_editor.parameter_definition_model: [("fish",)], + self.spine_db_editor.parameter_value_model: [ + ("fish__dog", DB_ITEM_SEPARATOR.join(["nemo", "scooby"])), + ("fish", "nemo"), + ("dog", "scooby"), ], } self._assert_filter(filtered_values) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorRemove.py b/tests/spine_db_editor/widgets/test_SpineDBEditorRemove.py index cbc74bb59..c1b0ddf7f 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorRemove.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorRemove.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,22 +10,19 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TreeViewFormRemoveMixin. -""" -from unittest import mock -from spinetoolbox.spine_db_editor.mvcmodels.compound_parameter_models import CompoundParameterModel +"""Unit tests for database item removal functionality in Database editor.""" +from .spine_db_editor_test_base import DBEditorTestBase -class TestSpineDBEditorRemoveMixin: +class TestSpineDBEditorRemove(DBEditorTestBase): def test_remove_object_classes_from_object_tree_model(self): """Test that object classes are removed from the object tree model.""" self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item self.assertEqual(root_item.child_count(), 2) - self.db_mngr.remove_items({self.mock_db_map: {"object_class": {self.fish_class["id"]}}}) + self.db_mngr.remove_items({self.mock_db_map: {"entity_class": {self.fish_class["id"]}}}) dog_item = root_item.child(0) self.assertEqual(root_item.child_count(), 1) self.assertEqual(dog_item.display_data, "dog") @@ -34,11 +32,11 @@ def test_remove_objects_from_object_tree_model(self): self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) self.assertEqual(fish_item.child_count(), 1) - self.db_mngr.remove_items({self.mock_db_map: {"object": {self.nemo_object["id"]}}}) + self.db_mngr.remove_items({self.mock_db_map: {"entity": {self.nemo_object["id"]}}}) self.assertEqual(fish_item.child_count(), 0) def test_remove_relationship_classes_from_object_tree_model(self): @@ -47,13 +45,11 @@ def test_remove_relationship_classes_from_object_tree_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item = root_item.child(0) - pluto_item = dog_item.child(0) - self.assertEqual(pluto_item.child_count(), 2) - self.db_mngr.remove_items({self.mock_db_map: {"relationship_class": {self.fish_dog_class["id"]}}}) - self.assertEqual(pluto_item.child_count(), 1) + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item + self.assertEqual(root_item.child_count(), 4) + self.db_mngr.remove_items({self.mock_db_map: {"entity_class": {self.fish_dog_class["id"]}}}) + self.assertEqual(root_item.child_count(), 3) def test_remove_relationships_from_object_tree_model(self): """Test that relationships are removed from the object tree model.""" @@ -62,40 +58,40 @@ def test_remove_relationships_from_object_tree_model(self): self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() self.put_mock_relationships_in_db_mngr() - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item - fish_item = root_item.child(1) + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item + fish_item = next(iter(item for item in root_item.children if item.display_data == "fish")) nemo_item = fish_item.child(0) - nemo_fish_dog_item = nemo_item.child(1) - relationships = [x.display_data for x in nemo_fish_dog_item.children] - self.assertEqual(nemo_fish_dog_item.child_count(), 2) - self.assertEqual(relationships[0], "pluto") - self.assertEqual(relationships[1], "scooby") - self.db_mngr.remove_items({self.mock_db_map: {"relationship": {self.nemo_pluto_rel["id"]}}}) - self.assertEqual(nemo_fish_dog_item.child_count(), 1) + relationships = [x.display_id for x in nemo_item.children] + self.assertEqual(nemo_item.child_count(), 3) + self.assertTrue("dog__fish" in relationships[0] and "pluto" in relationships[0][1]) + self.assertTrue("fish__dog" in relationships[1] and "pluto" in relationships[1][1]) + self.assertTrue("fish__dog" in relationships[2] and "scooby" in relationships[2][1]) + self.db_mngr.remove_items({self.mock_db_map: {"entity": {self.nemo_pluto_rel["id"]}}}) + self.assertEqual(nemo_item.child_count(), 2) def test_remove_object_parameter_definitions_from_model(self): """Test that object parameter definitions are removed from the model.""" - model = self.spine_db_editor.object_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() self.db_mngr.remove_items({self.mock_db_map: {"parameter_definition": {self.water_parameter["id"]}}}) h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("object_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("dog", "breed") in parameters) self.assertTrue(("fish", "water") not in parameters) def test_remove_relationship_parameter_definitions_from_model(self): """Test that object parameter definitions are removed from the model.""" - model = self.spine_db_editor.relationship_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) @@ -103,33 +99,33 @@ def test_remove_relationship_parameter_definitions_from_model(self): self.put_mock_relationship_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_relationship_parameter_definitions_in_db_mngr() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() self.db_mngr.remove_items({self.mock_db_map: {"parameter_definition": {self.relative_speed_parameter["id"]}}}) h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("relationship_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("dog__fish", "combined_mojo") in parameters) self.assertTrue(("fish__dog", "relative_speed") not in parameters) def test_remove_object_parameter_values_from_model(self): """Test that object parameter values are removed from the model.""" - model = self.spine_db_editor.object_parameter_value_model + model = self.spine_db_editor.parameter_value_model model.init_model() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_object_parameter_values_in_db_mngr() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() self.db_mngr.remove_items({self.mock_db_map: {"parameter_value": {self.nemo_water["id"]}}}) h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( ( - model.index(row, h("object_name")).data(), + model.index(row, h("entity_byname")).data(), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) @@ -138,7 +134,7 @@ def test_remove_object_parameter_values_from_model(self): def test_remove_relationship_parameter_values_from_model(self): """Test that relationship parameter values are removed from the model.""" - model = self.spine_db_editor.relationship_parameter_value_model + model = self.spine_db_editor.parameter_value_model model.init_model() self.put_mock_dataset_in_db_mngr() self.db_mngr.remove_items({self.mock_db_map: {"parameter_value": {self.nemo_pluto_relative_speed["id"]}}}) @@ -147,7 +143,7 @@ def test_remove_relationship_parameter_values_from_model(self): for row in range(model.rowCount()): parameters.append( ( - model.index(row, h("object_name_list")).data(), + model.index(row, h("entity_byname")).data(), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py index 673e48e71..bca80d872 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorUpdate.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,24 +10,22 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TreeViewFormUpdateMixin. -""" - +"""Unit tests for database item update functionality in Database editor.""" from spinetoolbox.helpers import DB_ITEM_SEPARATOR +from .spine_db_editor_test_base import DBEditorTestBase -class TestSpineDBEditorUpdateMixin: +class TestSpineDBEditorUpdate(DBEditorTestBase): def test_update_object_classes_in_object_tree_model(self): """Test that object classes are updated in the object tree model.""" self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() - self.fetch_object_tree_model() - self.fish_class = self._object_class(1, "octopus", "An octopus.", 1, None) - self.db_mngr.update_object_classes({self.mock_db_map: [self.fish_class]}) - root_item = self.spine_db_editor.object_tree_model.root_item + self.fetch_entity_tree_model() + self.fish_class = self._entity_class(1, "octopus") + self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_class]}) + root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) - self.assertEqual(fish_item.item_type, "object_class") + self.assertEqual(fish_item.item_type, "entity_class") self.assertEqual(fish_item.display_data, "octopus") def test_update_objects_in_object_tree_model(self): @@ -34,15 +33,13 @@ def test_update_objects_in_object_tree_model(self): self.spine_db_editor.init_models() self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() - self.fetch_object_tree_model() - self.nemo_object = self._object( - 1, self.fish_class["id"], self.fish_class["name"], 'dory', 'The one that forgets.' - ) - self.db_mngr.update_objects({self.mock_db_map: [self.nemo_object]}) - root_item = self.spine_db_editor.object_tree_model.root_item + self.fetch_entity_tree_model() + self.nemo_object = self._entity(1, self.fish_class["id"], "dory") + self.db_mngr.update_entities({self.mock_db_map: [self.nemo_object]}) + root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = root_item.child(1) nemo_item = fish_item.child(0) - self.assertEqual(nemo_item.item_type, "object") + self.assertEqual(nemo_item.item_type, "entity") self.assertEqual(nemo_item.display_data, "dory") def test_update_relationship_classes_in_object_tree_model(self): @@ -51,38 +48,36 @@ def test_update_relationship_classes_in_object_tree_model(self): self.put_mock_object_classes_in_db_mngr() self.put_mock_objects_in_db_mngr() self.put_mock_relationship_classes_in_db_mngr() - self.fetch_object_tree_model() + self.fetch_entity_tree_model() self.fish_dog_class = {"id": 3, "name": "octopus__dog"} - self.db_mngr.update_relationship_classes({self.mock_db_map: [self.fish_dog_class]}) - root_item = self.spine_db_editor.object_tree_model.root_item - dog_item = root_item.child(0) - pluto_item = dog_item.child(0) - pluto_fish_dog_item = pluto_item.child(1) - self.assertEqual(pluto_fish_dog_item.item_type, "relationship_class") - self.assertEqual(pluto_fish_dog_item.display_data, "octopus__dog") + self.db_mngr.update_entity_classes({self.mock_db_map: [self.fish_dog_class]}) + root_item = self.spine_db_editor.entity_tree_model.root_item + fish_dog_item = root_item.child(3) + self.assertEqual(fish_dog_item.item_type, "entity_class") + self.assertEqual(fish_dog_item.display_data, "octopus__dog") def test_update_object_parameter_definitions_in_model(self): """Test that object parameter definitions are updated in the model.""" - model = self.spine_db_editor.object_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) self.put_mock_object_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() - self.fetch_object_tree_model() - self.water_parameter = self._object_parameter_definition(1, self.fish_class["id"], "fish", "fire") + self.fetch_entity_tree_model() + self.water_parameter = self._parameter_definition(1, self.fish_class["id"], "fire") self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.water_parameter]}) h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("object_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("fish", "fire") in parameters) def test_update_relationship_parameter_definitions_in_model(self): """Test that object parameter definitions are updated in the model.""" - model = self.spine_db_editor.relationship_parameter_definition_model + model = self.spine_db_editor.parameter_definition_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) @@ -90,27 +85,20 @@ def test_update_relationship_parameter_definitions_in_model(self): self.put_mock_relationship_classes_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_relationship_parameter_definitions_in_db_mngr() - self.fetch_object_tree_model() - self.relative_speed_parameter = self._relationship_parameter_definition( - 3, - self.fish_dog_class["id"], - "fish__dog", - [self.fish_class["id"], self.dog_class["id"]], - ["fish", "dog"], - "each_others_opinion", - ) + self.fetch_entity_tree_model() + self.relative_speed_parameter = self._parameter_definition(3, self.fish_dog_class["id"], "each_others_opinion") self.db_mngr.update_parameter_definitions({self.mock_db_map: [self.relative_speed_parameter]}) h = model.header.index parameters = [] for row in range(model.rowCount()): parameters.append( - (model.index(row, h("relationship_class_name")).data(), model.index(row, h("parameter_name")).data()) + (model.index(row, h("entity_class_name")).data(), model.index(row, h("parameter_name")).data()) ) self.assertTrue(("fish__dog", "each_others_opinion") in parameters) def test_update_object_parameter_values_in_model(self): """Test that object parameter values are updated in the model.""" - model = self.spine_db_editor.object_parameter_value_model + model = self.spine_db_editor.parameter_value_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) @@ -118,18 +106,9 @@ def test_update_object_parameter_values_in_model(self): self.put_mock_objects_in_db_mngr() self.put_mock_object_parameter_definitions_in_db_mngr() self.put_mock_object_parameter_values_in_db_mngr() - self.fetch_object_tree_model() - self.nemo_water = self._object_parameter_value( - 1, - self.fish_class["id"], - "fish", - self.nemo_object["id"], - "nemo", - self.water_parameter["id"], - "water", - 1, - b'"pepper"', - None, + self.fetch_entity_tree_model() + self.nemo_water = self._parameter_value( + 1, self.fish_class["id"], self.nemo_object["id"], self.water_parameter["id"], 1, b'"pepper"', None ) self.db_mngr.update_parameter_values({self.mock_db_map: [self.nemo_water]}) h = model.header.index @@ -137,7 +116,7 @@ def test_update_object_parameter_values_in_model(self): for row in range(model.rowCount()): parameters.append( ( - model.index(row, h("object_name")).data(), + model.index(row, h("entity_byname")).data(), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) @@ -146,22 +125,16 @@ def test_update_object_parameter_values_in_model(self): def test_update_relationship_parameter_values_in_model(self): """Test that relationship parameter values are updated in the model.""" - model = self.spine_db_editor.relationship_parameter_value_model + model = self.spine_db_editor.parameter_value_model model.init_model() if model.canFetchMore(None): model.fetchMore(None) self.put_mock_dataset_in_db_mngr() - self.nemo_pluto_relative_speed = self._relationship_parameter_value( + self.nemo_pluto_relative_speed = self._parameter_value( 4, self.fish_dog_class["id"], - "fish__dog", - str(self.fish_class["id"]) + "," + str(self.dog_class["id"]), - "fish,dog", self.nemo_pluto_rel["id"], - str(self.nemo_object["id"]) + "," + str(self.pluto_object["id"]), - "nemo,pluto", self.relative_speed_parameter["id"], - "relative_speed", 1, b"100", None, @@ -172,7 +145,7 @@ def test_update_relationship_parameter_values_in_model(self): for row in range(model.rowCount()): parameters.append( ( - tuple((model.index(row, h("object_name_list")).data() or "").split(DB_ITEM_SEPARATOR)), + tuple((model.index(row, h("entity_byname")).data() or "").split(DB_ITEM_SEPARATOR)), model.index(row, h("parameter_name")).data(), model.index(row, h("value")).data(), ) diff --git a/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py b/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py index 8aafc38df..be9ab0442 100644 --- a/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py +++ b/tests/spine_db_editor/widgets/test_SpineDBEditorWithDBMapping.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,18 +10,17 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for SpineDBEditor classes. -""" +"""Unit tests for SpineDBEditor classes.""" import os.path from tempfile import TemporaryDirectory import unittest from unittest import mock import logging import sys +from PySide6.QtCore import QItemSelectionModel from PySide6.QtWidgets import QApplication from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from ...mock_helpers import TestSpineDBManager +from tests.mock_helpers import TestSpineDBManager class TestSpineDBEditorWithDBMapping(unittest.TestCase): @@ -34,8 +34,8 @@ def setUpClass(cls): logging.basicConfig( stream=sys.stderr, level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) def setUp(self): @@ -70,29 +70,53 @@ def tearDown(self): self.spine_db_editor = None self._temp_dir.cleanup() - def fetch_object_tree_model(self): - for item in self.spine_db_editor.object_tree_model.visit_all(): - if item.can_fetch_more(): + def fetch_entity_tree_model(self): + for item in self.spine_db_editor.entity_tree_model.visit_all(): + while item.can_fetch_more(): item.fetch_more() + QApplication.processEvents() - def test_duplicate_object_in_object_tree_model(self): + def test_duplicate_zero_dimensional_entity_in_entity_tree_model(self): data = { - "object_classes": ["fish", "dog"], - "relationship_classes": [("fish__dog", ("fish", "dog"))], - "objects": [("fish", "nemo"), ("dog", "pluto")], - "relationships": [("fish__dog", ("nemo", "pluto"))], - "object_parameters": [("fish", "color")], - "object_parameter_values": [("fish", "nemo", "color", "orange")], + "entity_classes": [("fish",), ("dog",), ("fish__dog", ("fish", "dog"))], + "entities": [("fish", "nemo"), ("dog", "pluto"), ("fish__dog", ("nemo", "pluto"))], + "parameter_definitions": [("fish", "color")], + "parameter_values": [("fish", "nemo", "color", "orange")], } self.db_mngr.import_data({self.db_map: data}) - self.fetch_object_tree_model() - root_item = self.spine_db_editor.object_tree_model.root_item + self.fetch_entity_tree_model() + root_item = self.spine_db_editor.entity_tree_model.root_item fish_item = next(iter(item for item in root_item.children if item.display_data == "fish")) nemo_item = fish_item.child(0) - self.spine_db_editor.duplicate_object(nemo_item) + with mock.patch.object(self.db_mngr, "error_msg") as error_msg_signal: + self.spine_db_editor.duplicate_entity(nemo_item) + error_msg_signal.emit.assert_not_called() + self.assertEqual(fish_item.row_count(), 2) nemo_dupe = fish_item.child(1) self.assertEqual(nemo_dupe.display_data, "nemo (1)") + fish_dog_item = next(iter(item for item in root_item.children if item.display_data == "fish__dog")) + fish_dog_item.fetch_more() + self.assertEqual(fish_dog_item.row_count(), 2) + nemo_pluto_dupe = fish_dog_item.child(1) + self.assertEqual(nemo_pluto_dupe.display_data, "nemo (1) ǀ pluto") + root_index = self.spine_db_editor.entity_tree_model.index_from_item(root_item) + self.spine_db_editor.ui.treeView_entity.selectionModel().setCurrentIndex( + root_index, QItemSelectionModel.SelectionFlags.ClearAndSelect + ) + while self.spine_db_editor.parameter_value_model.rowCount() != 3: + QApplication.processEvents() + expected = [ + ["fish", "nemo", "color", "Base", "orange", "db"], + ["fish", "nemo (1)", "color", "Base", "orange", "db"], + [None, None, None, None, None, "db"], + ] + for row in range(3): + for column in range(self.spine_db_editor.parameter_value_model.columnCount()): + with self.subTest(row=row, column=column): + self.assertEqual( + self.spine_db_editor.parameter_value_model.index(row, column).data(), expected[row][column] + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_add_items_dialog.py b/tests/spine_db_editor/widgets/test_add_items_dialog.py index aab9a51cf..b1230b04e 100644 --- a/tests/spine_db_editor/widgets/test_add_items_dialog.py +++ b/tests/spine_db_editor/widgets/test_add_items_dialog.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,21 +10,16 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ``add_items_dialog`` module. -""" - +"""Unit tests for ``add_items_dialog`` module.""" import unittest from unittest import mock from tempfile import TemporaryDirectory from PySide6.QtCore import QItemSelection, QItemSelectionModel, QModelIndex from PySide6.QtWidgets import QApplication -import spinetoolbox.resources_icons_rc # pylint: disable=unused-import -from spinetoolbox.helpers import signal_waiter from spinetoolbox.spine_db_manager import SpineDBManager from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddObjectClassesDialog, ManageRelationshipsDialog -from tests.spine_db_editor.widgets.helpers import TestBase +from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddEntityClassesDialog, ManageElementsDialog +from tests.spine_db_editor.helpers import TestBase class TestAddItemsDialog(unittest.TestCase): @@ -53,127 +49,163 @@ def tearDown(self): ), mock.patch("spinetoolbox.spine_db_manager.QMessageBox"): self._db_editor.close() self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._db_editor.deleteLater() self._db_editor = None self._temp_dir.cleanup() - def test_add_object_classes(self): - """Test object classes are added through the manager when accepting the dialog.""" - dialog = AddObjectClassesDialog(self._db_editor, self._db_mngr, self._db_map) + def test_add_entity_classes(self): + """Test entity classes are added through the manager when accepting the dialog.""" + dialog = AddEntityClassesDialog( + self._db_editor, self._db_editor.entity_tree_model.root_item, self._db_mngr, self._db_map + ) model = dialog.model header = model.header model.fetchMore(QModelIndex()) - self.assertEqual(header, ['object_class name', 'description', 'display icon', 'databases']) - indexes = [model.index(0, header.index(field)) for field in ('object_class name', 'databases')] - values = ['fish', 'mock_db'] + self.assertEqual(header, ["entity class name", "description", "display icon", "active by default", "databases"]) + indexes = [model.index(0, header.index(field)) for field in ("entity class name", "databases")] + values = ["fish", "mock_db"] model.batch_set_data(indexes, values) dialog.accept() self._commit_changes_to_database("Add object class.") - data = self._db_mngr.query(self._db_map, "object_class_sq") + data = self._db_map.query(self._db_map.object_class_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "fish") - def test_do_not_add_object_classes_with_invalid_db(self): - """Test object classes aren't added when the database is not correct.""" - dialog = AddObjectClassesDialog(self._db_editor, self._db_mngr, self._db_map) + def test_do_not_add_entity_classes_with_invalid_db(self): + """Test entity classes aren't added when the database is not correct.""" + dialog = AddEntityClassesDialog( + self._db_editor, self._db_editor.entity_tree_model.root_item, self._db_mngr, self._db_map + ) self._db_editor.msg_error = mock.NonCallableMagicMock() self._db_editor.msg_error.attach_mock(mock.MagicMock(), "emit") model = dialog.model header = model.header model.fetchMore(QModelIndex()) - self.assertEqual(header, ['object_class name', 'description', 'display icon', 'databases']) - indexes = [model.index(0, header.index(field)) for field in ('object_class name', 'databases')] - values = ['fish', 'gibberish'] + self.assertEqual(header, ["entity class name", "description", "display icon", "active by default", "databases"]) + indexes = [model.index(0, header.index(field)) for field in ("entity class name", "databases")] + values = ["fish", "gibberish"] model.batch_set_data(indexes, values) dialog.accept() - self._db_editor.msg_error.emit.assert_called_with("Invalid database 'gibberish' at row 1") + self._db_editor.msg_error.emit.assert_called_with("Invalid database gibberish at row 1") + + def test_pasting_data_to_active_by_default_column(self): + dialog = AddEntityClassesDialog( + self._db_editor, self._db_editor.entity_tree_model.root_item, self._db_mngr, self._db_map + ) + model = dialog.model + header = model.header + model.fetchMore(QModelIndex()) + self.assertEqual(header, ["entity class name", "description", "display icon", "active by default", "databases"]) + active_by_default_column = header.index("active by default") + index = model.index(0, active_by_default_column) + self.assertFalse(index.data()) + dialog.table_view.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect) + self._paste_to_table_view("true", dialog) + self.assertTrue(model.index(0, active_by_default_column).data()) + self._paste_to_table_view("GIBBERISH", dialog) + self.assertFalse(model.index(0, active_by_default_column).data()) + + def test_pasting_data_to_display_icon_column(self): + dialog = AddEntityClassesDialog( + self._db_editor, self._db_editor.entity_tree_model.root_item, self._db_mngr, self._db_map + ) + model = dialog.model + header = model.header + model.fetchMore(QModelIndex()) + self.assertEqual(header, ["entity class name", "description", "display icon", "active by default", "databases"]) + display_icon_column = header.index("display icon") + index = model.index(0, display_icon_column) + self.assertIsNone(index.data()) + dialog.table_view.selectionModel().setCurrentIndex(index, QItemSelectionModel.SelectionFlag.ClearAndSelect) + self._paste_to_table_view("23", dialog) + self.assertEqual(model.index(0, display_icon_column).data(), 23) + self._paste_to_table_view("GIBBERISH", dialog) + self.assertIsNone(model.index(0, display_icon_column).data()) + + @staticmethod + def _paste_to_table_view(text, dialog): + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = text + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + dialog.table_view.paste() def _commit_changes_to_database(self, commit_message): with mock.patch.object(self._db_editor, "_get_commit_msg") as commit_msg: commit_msg.return_value = commit_message - with signal_waiter(self._db_mngr.session_committed) as waiter: - self._db_editor.ui.actionCommit.trigger() - waiter.wait() + self._db_editor.ui.actionCommit.trigger() -class TestManageRelationshipsDialog(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - - def tearDown(self): - self._common_tear_down() - +class TestManageElementsDialog(TestBase): def test_add_relationship_among_existing_ones(self): - self._db_mngr.add_object_classes({self._db_map: [{"name": "Object_1", "id": 1}, {"name": "Object_2", "id": 2}]}) - self._db_mngr.add_objects( + self._db_mngr.add_entity_classes({self._db_map: [{"name": "Object_1", "id": 1}, {"name": "Object_2", "id": 2}]}) + self._db_mngr.add_entities( { self._db_map: [ - {"class_id": 1, "name": "object_11"}, - {"class_id": 1, "name": "object_12"}, - {"class_id": 2, "name": "object_21"}, + {"class_id": 1, "name": "object_11", "id": 1}, + {"class_id": 1, "name": "object_12", "id": 2}, + {"class_id": 2, "name": "object_21", "id": 3}, ] } ) - self._db_mngr.add_relationship_classes( - {self._db_map: [{"name": "rc", "id": 3, "object_class_id_list": [1, 2]}]} - ) - self._db_mngr.add_relationships({self._db_map: [{"name": "r", "class_id": 3, "object_id_list": [1, 3]}]}) - root_index = self._db_editor.relationship_tree_model.index(0, 0) - class_index = self._db_editor.relationship_tree_model.index(0, 0, root_index) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "rc", "id": 3, "dimension_id_list": [1, 2]}]}) + self._db_mngr.add_entities({self._db_map: [{"name": "r", "class_id": 3, "element_id_list": [1, 3], "id": 4}]}) + root_index = self._db_editor.entity_tree_model.index(0, 0) + class_index = self._db_editor.entity_tree_model.index(2, 0, root_index) self.assertEqual(class_index.data(), "rc") - relationship_item = self._db_editor.relationship_tree_model.item_from_index(class_index) - dialog = ManageRelationshipsDialog(self._db_editor, relationship_item, self._db_mngr, self._db_map) + relationship_item = self._db_editor.entity_tree_model.item_from_index(class_index) + dialog = ManageElementsDialog(self._db_editor, relationship_item, self._db_mngr, self._db_map) self.assertEqual(dialog.existing_items_model.rowCount(), 1) - self.assertEqual(dialog.existing_items_model.columnCount(), 2) + self.assertEqual(dialog.existing_items_model.columnCount(), 3) self.assertEqual(dialog.existing_items_model.index(0, 0).data(), "object_11") self.assertEqual(dialog.existing_items_model.index(0, 1).data(), "object_21") + self.assertEqual(dialog.existing_items_model.index(0, 2).data(), "r") self.assertEqual(dialog.new_items_model.rowCount(), 0) for tree_widget in dialog.splitter_widgets(): tree_widget.selectAll() - dialog.add_relationships() + dialog.add_entities() self.assertEqual(dialog.new_items_model.rowCount(), 1) - self.assertEqual(dialog.new_items_model.columnCount(), 2) + self.assertEqual(dialog.new_items_model.columnCount(), 3) self.assertEqual(dialog.new_items_model.index(0, 0).data(), "object_12") self.assertEqual(dialog.new_items_model.index(0, 1).data(), "object_21") + self.assertEqual(dialog.new_items_model.index(0, 2).data(), "object_12__object_21") def test_accept_relationship_removal(self): - self._db_mngr.add_object_classes({self._db_map: [{"name": "Object_1", "id": 1}, {"name": "Object_2", "id": 2}]}) - self._db_mngr.add_objects( + self._db_mngr.add_entity_classes({self._db_map: [{"name": "Object_1", "id": 1}, {"name": "Object_2", "id": 2}]}) + self._db_mngr.add_entities( { self._db_map: [ - {"class_id": 1, "name": "object_11"}, - {"class_id": 1, "name": "object_12"}, - {"class_id": 2, "name": "object_21"}, + {"class_id": 1, "name": "object_11", "id": 1}, + {"class_id": 1, "name": "object_12", "id": 2}, + {"class_id": 2, "name": "object_21", "id": 3}, ] } ) - self._db_mngr.add_relationship_classes( - {self._db_map: [{"name": "rc", "id": 3, "object_class_id_list": [1, 2]}]} - ) - self._db_mngr.add_relationships( + self._db_mngr.add_entity_classes({self._db_map: [{"name": "rc", "id": 3, "dimension_id_list": [1, 2]}]}) + self._db_mngr.add_entities( { self._db_map: [ - {"name": "r11", "class_id": 3, "object_id_list": [1, 3]}, - {"name": "r21", "class_id": 3, "object_id_list": [2, 3]}, + {"name": "r11", "class_id": 3, "element_id_list": [1, 3], "id": 4}, + {"name": "r21", "class_id": 3, "element_id_list": [2, 3], "id": 5}, ] } ) - root_index = self._db_editor.relationship_tree_model.index(0, 0) - class_index = self._db_editor.relationship_tree_model.index(0, 0, root_index) + root_index = self._db_editor.entity_tree_model.index(0, 0) + class_index = self._db_editor.entity_tree_model.index(2, 0, root_index) self.assertEqual(class_index.data(), "rc") - relationship_item = self._db_editor.relationship_tree_model.item_from_index(class_index) - dialog = ManageRelationshipsDialog(self._db_editor, relationship_item, self._db_mngr, self._db_map) + relationship_item = self._db_editor.entity_tree_model.item_from_index(class_index) + dialog = ManageElementsDialog(self._db_editor, relationship_item, self._db_mngr, self._db_map) self.assertEqual(dialog.existing_items_model.rowCount(), 2) - self.assertEqual(dialog.existing_items_model.columnCount(), 2) + self.assertEqual(dialog.existing_items_model.columnCount(), 3) self.assertEqual(dialog.existing_items_model.index(0, 0).data(), "object_11") self.assertEqual(dialog.existing_items_model.index(0, 1).data(), "object_21") self.assertEqual(dialog.existing_items_model.index(1, 0).data(), "object_12") self.assertEqual(dialog.existing_items_model.index(1, 1).data(), "object_21") self.assertEqual(dialog.table_view.model().rowCount(), 2) - self.assertEqual(dialog.table_view.model().columnCount(), 2) + self.assertEqual(dialog.table_view.model().columnCount(), 3) top_left = dialog.table_view.model().index(0, 0) bottom_right = dialog.table_view.model().index(0, 1) self.assertEqual(top_left.data(), "object_11") @@ -184,21 +216,12 @@ def test_accept_relationship_removal(self): dialog.remove_selected_rows() self.assertEqual(dialog.existing_items_model.rowCount(), 1) dialog.accept() - relationships = self._db_mngr.get_items(self._db_map, "relationship") + relationships = [x.resolve() for x in self._db_mngr.get_items(self._db_map, "entity") if x["element_id_list"]] self.assertEqual( relationships, - [ - { - 'class_id': 3, - 'commit_id': 2, - 'id': 5, - 'name': 'r21', - 'object_class_id_list': (1, 2), - 'object_id_list': (2, 3), - } - ], + [{"class_id": 3, "description": None, "id": 5, "name": "r21", "element_id_list": (2, 3)}], ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_commit_viewer.py b/tests/spine_db_editor/widgets/test_commit_viewer.py new file mode 100644 index 000000000..1baa4bda0 --- /dev/null +++ b/tests/spine_db_editor/widgets/test_commit_viewer.py @@ -0,0 +1,90 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for ``commit_viewer`` module.""" +import unittest +from unittest import mock + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QApplication +import spinetoolbox.resources_icons_rc # pylint: disable=unused-import +from spinetoolbox.fetch_parent import FlexibleFetchParent +from spinetoolbox.spine_db_manager import SpineDBManager +from spinetoolbox.spine_db_editor.widgets.commit_viewer import CommitViewer, QSplitter +from tests.mock_helpers import q_object + + +class TestCommitViewer(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def setUp(self): + with mock.patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"): + mock_settings = mock.Mock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self._db_mngr = SpineDBManager(mock_settings, None, synchronous=True) + logger = mock.MagicMock() + url = "sqlite://" + self._db_map = self._db_mngr.get_db_map(url, logger, codename="mock_db", create=True) + with mock.patch.object(QSplitter, "restoreState"): + self._commit_viewer = CommitViewer(mock_settings, self._db_mngr, self._db_map) + + def tearDown(self): + self._commit_viewer.close() + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + + def test_tab_count(self): + self.assertEqual(self._commit_viewer.centralWidget().count(), 1) + + def test_initial_commit_shows_in_list(self): + tab_widget = self._commit_viewer.centralWidget() + self.assertEqual(tab_widget.currentIndex(), 0) + current_tab = tab_widget.currentWidget() + commit_list = current_tab._ui.commit_list + self.assertEqual(commit_list.topLevelItemCount(), 1) + initial_commit_item = commit_list.topLevelItem(0) + commit_db_items = self._db_map.get_items("commit") + self.assertEqual(initial_commit_item.data(0, Qt.ItemDataRole.UserRole + 1), commit_db_items[0]["id"]) + + def test_selecting_initial_commit_shows_base_alternative(self): + with q_object(FlexibleFetchParent("alternative")) as fetch_parent: + self._db_mngr.fetch_more(self._db_map, fetch_parent) + tab_widget = self._commit_viewer.centralWidget() + self.assertEqual(tab_widget.currentIndex(), 0) + current_tab = tab_widget.currentWidget() + commit_list = current_tab._ui.commit_list + initial_commit_item = commit_list.topLevelItem(0) + commit_list.setCurrentItem(initial_commit_item) + affected_item_tab_widget = current_tab._ui.affected_item_tab_widget + while affected_item_tab_widget.count() != 1: + QApplication.processEvents() + affected_items_table = affected_item_tab_widget.widget(0).table + while affected_items_table.rowCount() != 1: + QApplication.processEvents() + self.assertEqual(affected_items_table.columnCount(), 2) + self.assertEqual(affected_items_table.horizontalHeaderItem(0).text(), "name") + self.assertEqual(affected_items_table.horizontalHeaderItem(1).text(), "description") + expected = [["Base", "Base alternative"]] + for row in range(affected_items_table.rowCount()): + expected_row = expected[row] + for column in range(affected_items_table.columnCount()): + with self.subTest(row=row, column=column): + self.assertEqual(affected_items_table.item(row, column).text(), expected_row[column]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_delegates.py b/tests/spine_db_editor/widgets/test_custom_delegates.py new file mode 100644 index 000000000..1f0d20ddd --- /dev/null +++ b/tests/spine_db_editor/widgets/test_custom_delegates.py @@ -0,0 +1,64 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for ``custom_delegates`` module.""" +import unittest +from unittest import mock +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import QApplication +from spinetoolbox.helpers import signal_waiter +from spinetoolbox.spine_db_editor.widgets.custom_delegates import BooleanValueDelegate + + +class TestBooleanValueDelegate(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._model = QStandardItemModel() + row = [QStandardItem()] + self._model.appendRow(row) + self._delegate = BooleanValueDelegate(None, None) + + def tearDown(self): + self._model.deleteLater() + self._delegate.deleteLater() + + def test_set_model_data_emits_when_true_is_selected(self): + editor = mock.MagicMock() + index = self._model.index(0, 0) + for value in (True, False): + with self.subTest(value=value): + editor.data.return_value = value + with signal_waiter(self._delegate.data_committed, timeout=1.0) as waiter: + self._delegate.setModelData(editor, self._model, index) + waiter.wait() + self.assertEqual(len(waiter.args), 2) + self.assertEqual(waiter.args[0], index) + if value: + self.assertTrue(waiter.args[1]) + else: + self.assertFalse(waiter.args[1]) + + def test_set_model_data_does_not_emit_when_editor_value_is_unrecognized(self): + editor = mock.MagicMock() + index = self._model.index(0, 0) + editor.data.return_value = None + with mock.patch.object(self._delegate, "data_committed") as data_committed_signal: + self._delegate.setModelData(editor, self._model, index) + data_committed_signal.emit.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_editors.py b/tests/spine_db_editor/widgets/test_custom_editors.py index d3e63cef1..e51637aff 100644 --- a/tests/spine_db_editor/widgets/test_custom_editors.py +++ b/tests/spine_db_editor/widgets/test_custom_editors.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,34 +10,135 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ``custom_editors`` module. -""" - +"""Unit tests for ``custom_editors`` module.""" import unittest - -from PySide6.QtWidgets import QApplication - -from spinetoolbox.widgets.custom_editors import SearchBarEditor +from PySide6.QtWidgets import QApplication, QWidget, QStyleOptionViewItem +from PySide6.QtGui import QKeyEvent, QFocusEvent, QStandardItemModel, QStandardItem +from PySide6.QtCore import QEvent, Qt, QPoint +from spinetoolbox.spine_db_editor.widgets.custom_editors import ( + CustomLineEditor, + CustomComboBoxEditor, + ParameterValueLineEditor, + PivotHeaderTableLineEditor, + CheckListEditor, + IconColorEditor, + _CustomLineEditDelegate, + SearchBarEditor, + BooleanSearchBarEditor, +) +from spinetoolbox.helpers import make_icon_id +from spinetoolbox.resources_icons_rc import qInitResources +from tests.mock_helpers import q_object -class TestSearchBarEditor(unittest.TestCase): +class TestEditors(unittest.TestCase): @classmethod def setUpClass(cls): + qInitResources() if not QApplication.instance(): QApplication() - def setUp(self): - self._editor = SearchBarEditor(None) + def test_searchbar_editor_set_data_sorts_items_case_insensitively(self): + with q_object(QWidget()) as parent: + editor = SearchBarEditor(parent) + editor.set_data("a", ["d", "b", "a", "C"]) + rows = [editor.proxy_model.index(row, 0).data() for row in range(editor.proxy_model.rowCount())] + self.assertEqual(rows, ["a", "a", "b", "C", "d"]) + editor.set_base_offset(QPoint(0, 0)) + editor.update_geometry(QStyleOptionViewItem()) + editor.refit() + + def test_custom_line_editor(self): + with q_object(QWidget()) as parent: + editor = CustomLineEditor(parent) + editor.set_data("abc") + self.assertEqual("abc", editor.data()) + keypress_event = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier) + QApplication.sendEvent(editor, keypress_event) + so_event = QKeyEvent( + QEvent.Type.ShortcutOverride, Qt.Key.Key_Backspace, Qt.KeyboardModifier.ControlModifier + ) + QApplication.sendEvent(editor, so_event) + + def test_custom_combobox_editor(self): + with q_object(QWidget()) as parent: + editor = CustomComboBoxEditor(parent) + + def test_parameter_value_line_editor(self): + with q_object(QWidget()) as parent: + editor = ParameterValueLineEditor(parent) + editor.set_data(123) + self.assertEqual(123, editor.data()) + + def test_parameter_value_line_editor_set_data_aligns_text_correctly(self): + with q_object(QWidget()) as parent: + editor = ParameterValueLineEditor(parent) + editor.set_data("align_left") + self.assertTrue(editor.alignment() & Qt.AlignLeft) + editor.set_data(2.3) + self.assertTrue(editor.alignment() & Qt.AlignRight) + + def test_pivot_header_line_editor(self): + with q_object(QWidget()) as parent: + editor = PivotHeaderTableLineEditor(parent) + editor.fix_geometry() + + def test_custom_line_edit_delegate(self): + with q_object(QWidget()) as parent: + editor = SearchBarEditor(parent) + delegate = _CustomLineEditDelegate(editor) + model = QStandardItemModel() + model.appendRow(QStandardItem("abc")) + index = model.index(0, 0) + delegate.setModelData(editor, model, index) + editor = delegate.createEditor(parent, None, index) + editor.deleteLater() + delegate.eventFilter( + editor, QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier) + ) + delegate.eventFilter(editor, QFocusEvent(QEvent.Type.FocusOut, Qt.FocusReason.OtherFocusReason)) + delegate.eventFilter( + editor, QKeyEvent(QEvent.Type.ShortcutOverride, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier) + ) + + def test_checklist_editor_set_data(self): + with q_object(QWidget()) as parent: + editor = CheckListEditor(parent) + editor.set_data(["first", "second", "third"], ["first", "third"]) + self.assertEqual(editor.data(), "first,third") + + def test_checklist_editor_toggle_selection(self): + with q_object(QWidget()) as parent: + editor = CheckListEditor(parent) + editor.set_data(["first", "second", "third"], ["first", "third"]) + self.assertEqual(editor.data(), "first,third") + index = editor.model().index(1, 0) + editor.toggle_selected(index) + self.assertEqual(editor.data(), "first,third,second") + editor.toggle_selected(index) + self.assertEqual(editor.data(), "first,third") + + def test_checklist_editor_update_geometry(self): + with q_object(QWidget()) as parent: + editor = CheckListEditor(parent) + editor.update_geometry(QStyleOptionViewItem()) - def tearDown(self): - self._editor.deleteLater() + def test_icon_color_editor_set_data(self): + with q_object(QWidget()) as parent: + editor = IconColorEditor(parent) + cog_symbol = 0xF013 + gray = 0xFFAAAAAA + icon_id = make_icon_id(cog_symbol, gray) + editor.set_data(icon_id) + self.assertEqual(editor.data(), icon_id) - def test_set_data_sorts_items_case_insensitively(self): - self._editor.set_data("a", ["d", "b", "a", "C"]) - rows = [self._editor.proxy_model.index(row, 0).data() for row in range(self._editor.proxy_model.rowCount())] - self.assertEqual(rows, ["a", "a", "b", "C", "d"]) + def test_boolean_searchbar_editor(self): + with q_object(QWidget()) as parent: + editor = BooleanSearchBarEditor(parent) + editor.set_data(True, None) + retval = editor.data() + self.assertEqual(True, retval) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_menus.py b/tests/spine_db_editor/widgets/test_custom_menus.py new file mode 100644 index 000000000..42afc212a --- /dev/null +++ b/tests/spine_db_editor/widgets/test_custom_menus.py @@ -0,0 +1,61 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for ``custom_menus`` module.""" +import unittest +from unittest import mock +from PySide6.QtWidgets import QApplication, QWidget +from spinetoolbox.helpers import signal_waiter +from spinetoolbox.spine_db_editor.widgets.custom_menus import TabularViewCodenameFilterMenu + + +class TestTabularViewCodenameFilterMenu(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._parent = QWidget() + + def tearDown(self): + self._parent.deleteLater() + + def test_init_fills_filter_list_with_database_codenames(self): + db_map1 = mock.MagicMock() + db_map1.codename = "db map 1" + db_map2 = mock.MagicMock() + db_map2.codename = "db map 2" + db_maps = [db_map1, db_map2] + menu = TabularViewCodenameFilterMenu(self._parent, db_maps, "database") + self.assertIs(menu.anchor, self._parent) + filter_list_model = menu._filter._filter_model + filter_rows = [] + for row in range(filter_list_model.rowCount()): + filter_rows.append(filter_list_model.index(row, 0).data()) + self.assertEqual(filter_rows, ["(Select all)", "(Empty)", "db map 1", "db map 2"]) + + def test_filter_changed_signal_is_emitted_correctly(self): + db_map1 = mock.MagicMock() + db_map1.codename = "db map 1" + db_map2 = mock.MagicMock() + db_map2.codename = "db map 2" + db_maps = [db_map1, db_map2] + menu = TabularViewCodenameFilterMenu(self._parent, db_maps, "database") + with signal_waiter(menu.filterChanged, timeout=0.1) as waiter: + menu._clear_filter() + waiter.wait() + self.assertEqual(waiter.args, ("database", {None, "db map 1", "db map 2"}, False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_qtableview.py b/tests/spine_db_editor/widgets/test_custom_qtableview.py index 5fbf61931..f33e9aa11 100644 --- a/tests/spine_db_editor/widgets/test_custom_qtableview.py +++ b/tests/spine_db_editor/widgets/test_custom_qtableview.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -17,21 +18,69 @@ from unittest import mock from PySide6.QtCore import QItemSelectionModel, QModelIndex from PySide6.QtWidgets import QApplication, QMessageBox +from spinedb_api import Array, DatabaseMapping, import_functions, to_database +from tests.mock_helpers import fetch_model +from tests.spine_db_editor.helpers import TestBase +from tests.spine_db_editor.widgets.helpers import ( + add_entity, + add_zero_dimension_entity_class, + EditorDelegateMocking, +) -from spinedb_api import DatabaseMapping, import_functions -from spinetoolbox.helpers import signal_waiter -from tests.spine_db_editor.widgets.helpers import add_object, add_object_class, TestBase, EditorDelegateMocking +class TestParameterDefinitionTableView(TestBase): + def test_plotting(self): + self.assert_success(self._db_map.add_entity_class_item(name="Object")) + value, value_type = to_database(Array([2.3, 23.0])) + self.assert_success( + self._db_map.add_parameter_definition_item( + name="q", entity_class_name="Object", default_value=value, default_type=value_type + ) + ) + table_view = self._db_editor.ui.tableView_parameter_definition + model = table_view.model() + fetch_model(model) + index = model.index(0, 3) + plot_widget = table_view._plot_selection([index]) + try: + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestParameterDefinitionTableView_db | Object | q") + self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "i") + self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "q") + legend = plot_widget.canvas.legend_axes.get_legend() + self.assertIsNone(legend) + lines = plot_widget.canvas.axes.get_lines() + self.assertEqual(len(lines), 1) + self.assertEqual(list(lines[0].get_xdata(orig=True)), [0, 1]) + self.assertEqual(list(lines[0].get_ydata(orig=True)), [2.3, 23.0]) + finally: + plot_widget.deleteLater() -class TestParameterTableView(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - def tearDown(self): - self._common_tear_down() +class TestParameterValueTableView(TestBase): + def test_paste_empty_string_to_entity_byname_column(self): + table_view = self._db_editor.ui.tableView_parameter_value + model = table_view.model() + byname_column = model.header.index("entity_byname") + table_view.selectionModel().setCurrentIndex( + model.index(0, byname_column), QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "''" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(table_view.paste()) + self.assertEqual(model.rowCount(), 1) + self.assertEqual(model.columnCount(), 6) + expected = [ + [None, "", None, None, None, "TestParameterValueTableView_db"], + ] + for row in range(model.rowCount()): + for column in range(model.columnCount()): + with self.subTest(row=row, column=column): + self.assertEqual(model.index(row, column).data(), expected[row][column]) def test_remove_last_empty_row(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() index = model.index(0, 0) selection_model = table_view.selectionModel() @@ -41,10 +90,10 @@ def test_remove_last_empty_row(self): self.assertEqual(model.rowCount(), 1) def test_remove_rows_from_empty_model(self): - tree_view = self._db_editor.ui.treeView_object - add_object_class(tree_view, "an_object_class") - add_object(tree_view, "an_object") - table_view = self._db_editor.ui.tableView_object_parameter_value + tree_view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(tree_view, "an_object_class") + add_entity(tree_view, "an_object") + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), 1) index = model.index(0, 0) @@ -57,13 +106,13 @@ def test_remove_rows_from_empty_model(self): self.assertEqual(model.index(0, 2).data(), None) self.assertEqual(model.index(0, 3).data(), None) self.assertEqual(model.index(0, 4).data(), None) - self.assertEqual(model.index(0, 5).data(), "database") + self.assertEqual(model.index(0, 5).data(), self.db_codename) self.assertEqual(model.index(1, 0).data(), None) self.assertEqual(model.index(1, 1).data(), None) self.assertEqual(model.index(1, 2).data(), None) self.assertEqual(model.index(1, 3).data(), None) self.assertEqual(model.index(1, 4).data(), None) - self.assertEqual(model.index(1, 5).data(), "database") + self.assertEqual(model.index(1, 5).data(), self.db_codename) selection_model = table_view.selectionModel() selection_model.select(index, QItemSelectionModel.ClearAndSelect) table_view.remove_selected() @@ -76,17 +125,17 @@ def test_remove_rows_from_empty_model(self): self.assertFalse(selection_model.hasSelection()) def test_removing_row_does_not_allow_fetching_more_data(self): - tree_view = self._db_editor.ui.treeView_object - add_object_class(tree_view, "an_object_class") - add_object(tree_view, "object_1") - add_object(tree_view, "object_2") - definition_table_view = self._db_editor.ui.tableView_object_parameter_definition + tree_view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(tree_view, "an_object_class") + add_entity(tree_view, "object_1") + add_entity(tree_view, "object_2") + definition_table_view = self._db_editor.ui.tableView_parameter_definition definition_model = definition_table_view.model() delegate_mock = EditorDelegateMocking() delegate_mock.write_to_index(definition_table_view, definition_model.index(0, 0), "an_object_class") delegate_mock.reset() delegate_mock.write_to_index(definition_table_view, definition_model.index(0, 1), "a_parameter") - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), 1) _set_row_data(table_view, model, 0, ["an_object_class", "object_1", "a_parameter", "Base"], delegate_mock) @@ -98,9 +147,9 @@ def test_removing_row_does_not_allow_fetching_more_data(self): self.assertEqual(model.rowCount(), 3) self.assertEqual(model.columnCount(), 6) expected = [ - ["an_object_class", "object_1", "a_parameter", "Base", "value_1", "database"], - ["an_object_class", "object_2", "a_parameter", "Base", "value_2", "database"], - [None, None, None, None, None, "database"], + ["an_object_class", "object_1", "a_parameter", "Base", "value_1", self.db_codename], + ["an_object_class", "object_2", "a_parameter", "Base", "value_2", self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) @@ -109,23 +158,23 @@ def test_removing_row_does_not_allow_fetching_more_data(self): table_view.remove_selected() self.assertFalse(model.canFetchMore(QModelIndex())) expected = [ - ["an_object_class", "object_2", "a_parameter", "Base", "value_2", "database"], - [None, None, None, None, None, "database"], + ["an_object_class", "object_2", "a_parameter", "Base", "value_2", self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) def test_receiving_uncommitted_but_existing_value_does_not_create_duplicate_entry(self): - tree_view = self._db_editor.ui.treeView_object - add_object_class(tree_view, "an_object_class") - add_object(tree_view, "an_object") - definition_table_view = self._db_editor.ui.tableView_object_parameter_definition + tree_view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(tree_view, "an_object_class") + add_entity(tree_view, "an_object") + definition_table_view = self._db_editor.ui.tableView_parameter_definition definition_model = definition_table_view.model() delegate_mock = EditorDelegateMocking() delegate_mock.write_to_index(definition_table_view, definition_model.index(0, 0), "an_object_class") delegate_mock.reset() delegate_mock.write_to_index(definition_table_view, definition_model.index(0, 1), "a_parameter") - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), 1) _set_row_data(table_view, model, 0, ["an_object_class", "an_object", "a_parameter", "Base"], delegate_mock) @@ -134,8 +183,8 @@ def test_receiving_uncommitted_but_existing_value_does_not_create_duplicate_entr self.assertEqual(model.rowCount(), 2) self.assertEqual(model.columnCount(), 6) expected = [ - ["an_object_class", "an_object", "a_parameter", "Base", "value_1", "database"], - [None, None, None, None, None, "database"], + ["an_object_class", "an_object", "a_parameter", "Base", "value_1", self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) @@ -146,18 +195,18 @@ def test_receiving_uncommitted_but_existing_value_does_not_create_duplicate_entr @mock.patch("spinetoolbox.spine_db_worker._CHUNK_SIZE", new=1) def test_incremental_fetching_groups_values_by_entity_class(self): - tree_view = self._db_editor.ui.treeView_object - add_object_class(tree_view, "object_1_class") - add_object(tree_view, "an_object_1") - add_object(tree_view, "another_object_1") - add_object_class(tree_view, "object_2_class") - add_object(tree_view, "an_object_2", object_class_index=1) - definition_table_view = self._db_editor.ui.tableView_object_parameter_definition + tree_view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(tree_view, "object_1_class") + add_entity(tree_view, "an_object_1") + add_entity(tree_view, "another_object_1") + add_zero_dimension_entity_class(tree_view, "object_2_class") + add_entity(tree_view, "an_object_2", entity_class_index=1) + definition_table_view = self._db_editor.ui.tableView_parameter_definition definition_model = definition_table_view.model() delegate_mock = EditorDelegateMocking() _set_row_data(definition_table_view, definition_model, 0, ["object_1_class", "parameter_1"], delegate_mock) _set_row_data(definition_table_view, definition_model, 1, ["object_2_class", "parameter_2"], delegate_mock) - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), 1) _set_row_data( @@ -176,31 +225,65 @@ def test_incremental_fetching_groups_values_by_entity_class(self): self.assertEqual(model.rowCount(), 4) self.assertEqual(model.columnCount(), 6) expected = [ - ["object_1_class", "an_object_1", "parameter_1", "Base", "a_value", "database"], - ["object_2_class", "an_object_2", "parameter_2", "Base", "b_value", "database"], - ["object_1_class", "another_object_1", "parameter_1", "Base", "c_value", "database"], - [None, None, None, None, None, "database"], + ["object_1_class", "an_object_1", "parameter_1", "Base", "a_value", self.db_codename], + ["object_2_class", "an_object_2", "parameter_2", "Base", "b_value", self.db_codename], + ["object_1_class", "another_object_1", "parameter_1", "Base", "c_value", self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) self._commit_changes_to_database("Add test data.") - with signal_waiter(self._db_mngr.session_refreshed) as waiter: - self._db_editor.refresh_session() - waiter.wait() + self._db_editor.refresh_session() while model.rowCount() != 4: model.fetchMore(QModelIndex()) QApplication.processEvents() expected = [ - ["object_1_class", "an_object_1", "parameter_1", "Base", "a_value", "database"], - ["object_1_class", "another_object_1", "parameter_1", "Base", "c_value", "database"], - ["object_2_class", "an_object_2", "parameter_2", "Base", "b_value", "database"], - [None, None, None, None, None, "database"], + ["object_1_class", "an_object_1", "parameter_1", "Base", "a_value", self.db_codename], + ["object_1_class", "another_object_1", "parameter_1", "Base", "c_value", self.db_codename], + ["object_2_class", "an_object_2", "parameter_2", "Base", "b_value", self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) + def test_plotting(self): + self.assert_success(self._db_map.add_entity_class_item(name="Object")) + self.assert_success(self._db_map.add_parameter_definition_item(name="q", entity_class_name="Object")) + self.assert_success(self._db_map.add_entity_item(name="baffling sphere", entity_class_name="Object")) + value, value_type = to_database(Array([2.3, 23.0])) + self.assert_success( + self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("baffling sphere",), + parameter_definition_name="q", + alternative_name="Base", + value=value, + type=value_type, + ) + ) + table_view = self._db_editor.ui.tableView_parameter_value + model = table_view.model() + fetch_model(model) + index = model.index(0, 4) + plot_widget = table_view._plot_selection([index]) + try: + self.assertEqual( + plot_widget.canvas.axes.get_title(), + "TestParameterValueTableView_db | Object | baffling sphere | q | Base", + ) + self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "i") + self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "q") + legend = plot_widget.canvas.legend_axes.get_legend() + self.assertIsNone(legend) + lines = plot_widget.canvas.axes.get_lines() + self.assertEqual(len(lines), 1) + self.assertEqual(list(lines[0].get_xdata(orig=True)), [0, 1]) + self.assertEqual(list(lines[0].get_ydata(orig=True)), [2.3, 23.0]) + finally: + plot_widget.deleteLater() + -class TestParameterTableWithExistingData(TestBase): +class TestParameterValueTableWithExistingData(TestBase): _CHUNK_SIZE = 100 # This has to be large enough, so the chunk won't 'fit' into the table view. @mock.patch("spinetoolbox.spine_db_worker._CHUNK_SIZE", new=_CHUNK_SIZE) @@ -221,9 +304,9 @@ def setUp(self): ) import_functions.import_object_parameter_values(db_map, parameter_value_data) db_map.commit_session("Add test data.") - db_map.connection.close() + db_map.close() self._common_setup(url, create=False) - model = self._db_editor.ui.tableView_object_parameter_value.model() + model = self._db_editor.ui.tableView_parameter_value.model() while model.rowCount() != self._CHUNK_SIZE + 1: # Wait for fetching to finish. QApplication.processEvents() @@ -233,14 +316,14 @@ def tearDown(self): self._temp_dir.cleanup() def test_purging_value_data_removes_all_rows(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) self._db_mngr.purge_items({self._db_map: ["parameter_value"]}) self.assertEqual(model.rowCount(), 1) def test_purging_value_data_leaves_empty_rows_intact(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) delegate_mock = EditorDelegateMocking() @@ -250,14 +333,14 @@ def test_purging_value_data_leaves_empty_rows_intact(self): self._db_mngr.purge_items({self._db_map: ["parameter_value"]}) self.assertEqual(model.rowCount(), 2) expected = [ - ["object_class", "object_1", "parameter_1", "Base", None, "database"], - [None, None, None, None, None, "database"], + ["object_class", "object_1", "parameter_1", "Base", None, self.db_codename], + [None, None, None, None, None, self.db_codename], ] for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) def test_removing_fetched_rows_allows_still_fetching_more(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) n_values = self._n_parameters * self._n_objects @@ -265,7 +348,7 @@ def test_removing_fetched_rows_allows_still_fetching_more(self): self.assertEqual(model.rowCount(), (self._CHUNK_SIZE) / 2 + 1) def test_undoing_purge(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) self._db_mngr.purge_items({self._db_map: ["parameter_value"]}) @@ -277,18 +360,18 @@ def test_undoing_purge(self): QApplication.processEvents() expected = sorted( [ - ["object_class", f"object_{object_n}", f"parameter_{parameter_n}", "Base", "a_value", "database"] + ["object_class", f"object_{object_n}", f"parameter_{parameter_n}", "Base", "a_value", self.db_codename] for object_n, parameter_n in itertools.product(range(self._n_objects), range(self._n_parameters)) ], key=lambda x: (x[1], x[2]), ) - expected.append([None, None, None, None, None, "database"]) + expected.append([None, None, None, None, None, self.db_codename]) self.assertEqual(model.rowCount(), self._n_objects * self._n_parameters + 1) for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) def test_rolling_back_purge(self): - table_view = self._db_editor.ui.tableView_object_parameter_value + table_view = self._db_editor.ui.tableView_parameter_value model = table_view.model() self.assertEqual(model.rowCount(), self._CHUNK_SIZE + 1) self._db_mngr.purge_items({self._db_map: ["parameter_value"]}) @@ -298,28 +381,54 @@ def test_rolling_back_purge(self): instance = roll_back_dialog.return_value instance.exec.return_value = QMessageBox.StandardButton.Ok self._db_editor.ui.actionRollback.trigger() + self._db_editor.rollback_session() while model.rowCount() != self._n_objects * self._n_parameters + 1: - # Wait for fetching to finish. + # Fetch the entire model, because we want to validate all the data. + model.fetchMore(QModelIndex()) QApplication.processEvents() expected = sorted( [ - ["object_class", f"object_{object_n}", f"parameter_{parameter_n}", "Base", "a_value", "database"] + ["object_class", f"object_{object_n}", f"parameter_{parameter_n}", "Base", "a_value", self.db_codename] for object_n, parameter_n in itertools.product(range(self._n_objects), range(self._n_parameters)) ], key=lambda x: (x[1], x[2]), ) QApplication.processEvents() - expected.append([None, None, None, None, None, "database"]) + expected.append([None, None, None, None, None, self.db_codename]) self.assertEqual(model.rowCount(), self._n_objects * self._n_parameters + 1) for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): self.assertEqual(model.index(row, column).data(), expected[row][column]) +class TestEntityAlternativeTableView(TestBase): + def test_pasting_gibberish_to_the_active_column_converts_to_false(self): + self._db_map.add_entity_class_item(name="Object") + self._db_map.add_entity_item(entity_class_name="Object", name="spoon") + table_view = self._db_editor.ui.tableView_entity_alternative + model = table_view.model() + table_view.selectionModel().setCurrentIndex(model.index(0, 0), QItemSelectionModel.SelectionFlag.ClearAndSelect) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "Object\tspoon\tBase\tGIBBERISH" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(table_view.paste()) + self.assertEqual(model.rowCount(), 2) + self.assertEqual(model.columnCount(), 5) + expected = [ + ["Object", "spoon", "Base", False, "TestEntityAlternativeTableView_db"], + [None, None, None, None, "TestEntityAlternativeTableView_db"], + ] + for row in range(model.rowCount()): + for column in range(model.columnCount()): + with self.subTest(row=row, column=column): + self.assertEqual(model.index(row, column).data(), expected[row][column]) + + def _set_row_data(view, model, row, data, delegate_mock): for column, cell_data in enumerate(data): delegate_mock.reset() delegate_mock.write_to_index(view, model.index(row, column), cell_data) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_custom_qtreeview.py b/tests/spine_db_editor/widgets/test_custom_qtreeview.py index 0826c043c..c527b10d4 100644 --- a/tests/spine_db_editor/widgets/test_custom_qtreeview.py +++ b/tests/spine_db_editor/widgets/test_custom_qtreeview.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,44 +10,32 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for DB editor's custom ``QTreeView`` classes. -""" +"""Unit tests for DB editor's custom ``QTreeView`` classes.""" import os.path from tempfile import TemporaryDirectory import unittest from unittest import mock from PySide6.QtCore import Qt, QItemSelectionModel from PySide6.QtWidgets import QApplication - from spinedb_api import ( DatabaseMapping, from_database, - import_object_classes, - import_objects, + import_entity_classes, + import_entities, import_parameter_value_lists, - import_relationship_classes, - import_relationships, - import_object_parameters, - import_tools, - import_features, - import_tool_features, - import_tool_feature_methods, ) -from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddRelationshipsDialog, AddRelationshipClassesDialog +from spinetoolbox.spine_db_editor.widgets.add_items_dialogs import AddEntitiesDialog, AddEntityClassesDialog from spinetoolbox.spine_db_editor.widgets.edit_or_remove_items_dialogs import ( - EditObjectClassesDialog, - EditObjectsDialog, + EditEntityClassesDialog, + EditEntitiesDialog, RemoveEntitiesDialog, - EditRelationshipClassesDialog, - EditRelationshipsDialog, ) +from tests.spine_db_editor.helpers import TestBase from tests.spine_db_editor.widgets.helpers import ( EditorDelegateMocking, add_entity_tree_item, - add_object_class, - add_object, - TestBase, + add_zero_dimension_entity_class, + add_entity, ) @@ -114,11 +103,11 @@ def _remove_entity_tree_item(view, menu_action_text, dialog_class): edit_items_dialog.accept() -def _remove_object_class(view): +def _remove_entity_class(view): _remove_entity_tree_item(view, "Remove...", RemoveEntitiesDialog) -def _remove_object(view): +def _remove_entity(view): _remove_entity_tree_item(view, "Remove...", RemoveEntitiesDialog) @@ -131,15 +120,9 @@ def _append_table_row(view, row): delegate_mock.write_to_index(view, index, value) -class TestObjectTreeViewWithInitiallyEmptyDatabase(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - - def tearDown(self): - self._common_tear_down() - +class TestEntityTreeViewWithInitiallyEmptyDatabase(TestBase): def test_empty_view(self): - view = self._db_editor.ui.treeView_object + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) self.assertEqual(model.rowCount(root_index), 0) @@ -148,82 +131,160 @@ def test_empty_view(self): self.assertEqual(model.headerData(0, Qt.Orientation.Horizontal), "name") self.assertEqual(model.headerData(1, Qt.Orientation.Horizontal), "database") - def test_add_object_class(self): - view = self._db_editor.ui.treeView_object - add_object_class(view, "an_object_class") + def test_add_class_with_zero_dimensions(self): + view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(view, "an_entity_class") model = view.model() root_index = model.index(0, 0) self.assertEqual(model.rowCount(root_index), 1) class_index = model.index(0, 0, root_index) self.assertEqual(model.rowCount(class_index), 0) - self.assertEqual(class_index.data(), "an_object_class") + self.assertEqual(class_index.data(), "an_entity_class") class_database_index = model.index(0, 1, root_index) - self.assertEqual(class_database_index.data(), "database") - self._commit_changes_to_database("Add object class.") - data = self._db_mngr.query(self._db_map, "object_class_sq") + self.assertEqual(class_database_index.data(), self.db_codename) + self._commit_changes_to_database("Add entity class.") + data = self._db_map.query(self._db_map.entity_class_sq).all() self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "an_object_class") + self.assertEqual(data[0].name, "an_entity_class") + + def test_add_class_with_single_dimension(self): + add_zero_dimension_entity_class(self._db_editor.ui.treeView_entity, "an_entity_class") + view = self._db_editor.ui.treeView_entity + model = view.model() + root_index = model.index(0, 0) + class_index = model.index(0, 0, root_index) + view._context_item = model.item_from_index(class_index) + self._add_multidimensional_class("a_relationship_class", ["an_entity_class"]) + class_index = model.index(0, 0, root_index) + self.assertEqual(model.rowCount(class_index), 0) + self.assertEqual(class_index.data(), "a_relationship_class") + class_database_index = model.index(0, 1, root_index) + self.assertEqual(class_database_index.data(), self.db_codename) + self._commit_changes_to_database("Add entity classes.") + entity_class = ( + self._db_map.query(self._db_map.wide_entity_class_sq) + .filter(self._db_map.wide_entity_class_sq.c.name == "a_relationship_class") + .one() + ) + self.assertEqual(entity_class.name, "a_relationship_class") + self.assertEqual(entity_class.dimension_name_list, "an_entity_class") - def test_add_object(self): - view = self._db_editor.ui.treeView_object - add_object_class(view, "an_object_class") - add_object(view, "an_object") + def test_add_entity(self): + view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(view, "an_entity_class") + add_entity(view, "an_entity") model = view.model() root_index = model.index(0, 0) class_index = model.index(0, 0, root_index) model.fetchMore(class_index) - QApplication.processEvents() + while model.rowCount(class_index) != 1: + QApplication.processEvents() self.assertEqual(model.rowCount(class_index), 1) - self.assertEqual(class_index.data(), "an_object_class") - object_index = model.index(0, 0, class_index) - self.assertEqual(model.rowCount(object_index), 0) - self.assertEqual(object_index.data(), "an_object") - object_database_index = model.index(0, 1, class_index) - self.assertEqual(object_database_index.data(), "database") - self._commit_changes_to_database("Add object.") - data = self._db_mngr.query(self._db_map, "object_class_sq") + self.assertEqual(class_index.data(), "an_entity_class") + entity_index = model.index(0, 0, class_index) + self.assertEqual(model.rowCount(entity_index), 0) + self.assertEqual(entity_index.data(), "an_entity") + entity_database_index = model.index(0, 1, class_index) + self.assertEqual(entity_database_index.data(), self.db_codename) + self._commit_changes_to_database("Add entity.") + data = self._db_map.query(self._db_map.entity_class_sq).all() self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "an_object_class") - data = self._db_mngr.query(self._db_map, "object_sq") + self.assertEqual(data[0].name, "an_entity_class") + data = self._db_map.query(self._db_map.entity_sq).all() self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "an_object") + self.assertEqual(data[0].name, "an_entity") - def test_add_relationship_class_from_object_tree_view(self): - object_tree_view = self._db_editor.ui.treeView_object - add_object_class(object_tree_view, "an_object_class") - object_model = object_tree_view.model() - root_index = object_model.index(0, 0) - object_class_index = object_model.index(0, 0, root_index) - object_tree_view._context_item = object_model.item_from_index(object_class_index) + def test_add_entity_with_single_dimension(self): + view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(view, "an_entity_class") + add_entity(view, "an_entity") + model = view.model() + root_index = model.index(0, 0) + class_index = model.index(0, 0, root_index) + view._context_item = model.item_from_index(class_index) + self._add_multidimensional_class("a_relationship_class", ["an_entity_class"]) + self.assertEqual(model.rowCount(root_index), 2) + class_index = model.index(0, 0, root_index) # Classes are sorted alphabetically. + self.assertEqual(class_index.data(), "a_relationship_class") + view._context_item = model.item_from_index(class_index) + self._add_multidimensional_entity("a_relationship", ["an_entity"]) + if model.canFetchMore(class_index): + model.fetchMore(class_index) + QApplication.processEvents() + self.assertEqual(model.rowCount(class_index), 1) + entity_index = model.index(0, 0, class_index) + self.assertEqual(model.rowCount(entity_index), 0) + self.assertEqual(entity_index.data(), "an_entity") + database_index = model.index(0, 1, class_index) + self.assertEqual(database_index.data(), self.db_codename) + self._commit_changes_to_database("Add an entities.") + class_id = ( + self._db_map.query(self._db_map.entity_class_sq) + .filter(self._db_map.entity_class_sq.c.name == "a_relationship_class") + .one() + .id + ) + entity = ( + self._db_map.query(self._db_map.wide_entity_sq) + .filter(self._db_map.wide_entity_sq.c.class_id == class_id) + .one() + ) + self.assertEqual(entity.name, "a_relationship") + self.assertEqual(entity.element_name_list, "an_entity") + + def test_add_entity_class_with_another_class_as_preselected_first_dimension(self): + entity_tree_view = self._db_editor.ui.treeView_entity + add_zero_dimension_entity_class(entity_tree_view, "an_entity_class") + entity_model = entity_tree_view.model() + root_index = entity_model.index(0, 0) + entity_class_index = entity_model.index(0, 0, root_index) + entity_tree_view._context_item = entity_model.item_from_index(entity_class_index) add_entity_tree_item( - {1: "a_relationship_class"}, object_tree_view, "Add relationship classes", AddRelationshipClassesDialog + {1: "my_first_dimension_is_an_entity_class"}, entity_tree_view, "Add entity classes", AddEntityClassesDialog ) - view = self._db_editor.ui.treeView_relationship + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) - class_index = model.index(0, 0, root_index) + class_index = model.index(1, 0, root_index) self.assertEqual(model.rowCount(class_index), 0) - self.assertEqual(class_index.data(), "a_relationship_class") + self.assertEqual(class_index.data(), "my_first_dimension_is_an_entity_class") class_database_index = model.index(0, 1, root_index) - self.assertEqual(class_database_index.data(), "database") - self._commit_changes_to_database("Add object and relationship classes.") - data = self._db_mngr.query(self._db_map, "wide_relationship_class_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "a_relationship_class") - self.assertEqual(data[0].object_class_name_list, "an_object_class") + self.assertEqual(class_database_index.data(), self.db_codename) + self._commit_changes_to_database("Add entity classes.") + data = self._db_map.query(self._db_map.wide_entity_class_sq).all() + self.assertEqual(len(data), 2) + self.assertEqual(data[0].name, "an_entity_class") + self.assertIsNone(data[0].dimension_name_list) + self.assertEqual(data[1].name, "my_first_dimension_is_an_entity_class") + self.assertEqual(data[1].dimension_name_list, "an_entity_class") + + def _add_multidimensional_class(self, class_name, dimension_names): + item_names = {i: name for i, name in enumerate(dimension_names)} + item_names[len(dimension_names)] = class_name + add_entity_tree_item( + item_names, + self._db_editor.ui.treeView_entity, + "Add entity classes", + AddEntityClassesDialog, + ) + + def _add_multidimensional_entity(self, element_name, entity_names): + item_names = {i: name for i, name in enumerate(entity_names)} + item_names[len(entity_names)] = element_name + add_entity_tree_item(item_names, self._db_editor.ui.treeView_entity, "Add entities", AddEntitiesDialog) -class TestObjectTreeViewWithExistingData(TestBase): +class TestEntityTreeViewWithExistingZeroDimensionalEntities(TestBase): def setUp(self): self._temp_dir = TemporaryDirectory() url = "sqlite:///" + os.path.join(self._temp_dir.name, "test_database.sqlite") db_map = DatabaseMapping(url, create=True) - import_object_classes(db_map, ("object_class_1",)) - import_objects(db_map, (("object_class_1", "object_1"), ("object_class_1", "object_2"))) - db_map.commit_session("Add objects.") - db_map.connection.close() + import_entity_classes(db_map, (("entity_class_1",),)) + import_entities(db_map, (("entity_class_1", "entity_1"), ("entity_class_1", "entity_2"))) + db_map.commit_session("Add entities.") + db_map.close() self._common_setup(url, create=False) - model = self._db_editor.ui.treeView_object.model() + model = self._db_editor.ui.treeView_entity.model() root_index = model.index(0, 0) while model.rowCount(root_index) != 1: # Wait for fetching to finish. @@ -234,7 +295,7 @@ def tearDown(self): self._temp_dir.cleanup() def test_database_contents_shown_correctly(self): - view = self._db_editor.ui.treeView_object + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) @@ -249,38 +310,38 @@ def test_database_contents_shown_correctly(self): while model.rowCount(class_index) != 2: QApplication.processEvents() self.assertEqual(model.columnCount(class_index), 2) - self.assertEqual(class_index.data(), "object_class_1") + self.assertEqual(class_index.data(), "entity_class_1") database_index = model.index(0, 1, root_index) - self.assertEqual(database_index.data(), "database") - object_index = model.index(0, 0, class_index) - self.assertEqual(model.rowCount(object_index), 0) - self.assertEqual(model.columnCount(object_index), 2) - self.assertEqual(object_index.data(), "object_1") + self.assertEqual(database_index.data(), self.db_codename) + entity_index = model.index(0, 0, class_index) + self.assertEqual(model.rowCount(entity_index), 0) + self.assertEqual(model.columnCount(entity_index), 2) + self.assertEqual(entity_index.data(), "entity_1") database_index = model.index(0, 1, class_index) - self.assertEqual(database_index.data(), "database") - object_index = model.index(1, 0, class_index) - self.assertEqual(model.rowCount(object_index), 0) - self.assertEqual(model.columnCount(object_index), 2) - self.assertEqual(object_index.data(), "object_2") + self.assertEqual(database_index.data(), self.db_codename) + entity_index = model.index(1, 0, class_index) + self.assertEqual(model.rowCount(entity_index), 0) + self.assertEqual(model.columnCount(entity_index), 2) + self.assertEqual(entity_index.data(), "entity_2") database_index = model.index(1, 1, class_index) - self.assertEqual(database_index.data(), "database") + self.assertEqual(database_index.data(), self.db_codename) - def test_rename_object_class(self): - view = self._db_editor.ui.treeView_object + def test_rename_entity_class(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) class_index = model.index(0, 0, root_index) view.setCurrentIndex(class_index) - self._rename_object_class("renamed_class") + self._rename_entity_class("renamed_class") class_index = model.index(0, 0, root_index) self.assertEqual(class_index.data(), "renamed_class") - self._commit_changes_to_database("Rename object class.") - data = self._db_mngr.query(self._db_map, "object_class_sq") + self._commit_changes_to_database("Rename entity class.") + data = self._db_map.query(self._db_map.entity_class_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "renamed_class") - def test_rename_object(self): - view = self._db_editor.ui.treeView_object + def test_rename_entity(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) @@ -290,30 +351,31 @@ def test_rename_object(self): model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() - object_index = model.index(0, 0, class_index) - view.setCurrentIndex(object_index) - self._rename_object("renamed_object") - object_index = model.index(0, 0, class_index) - self.assertEqual(object_index.data(), "renamed_object") - self._commit_changes_to_database("Rename object.") - data = self._db_mngr.query(self._db_map, "object_sq") + entity_index = model.index(0, 0, class_index) + view.setCurrentIndex(entity_index) + self._rename_entity("renamed_entity") + QApplication.processEvents() # Fixes a "silent" Traceback + entity_index = model.index(0, 0, class_index) + self.assertEqual(entity_index.data(), "renamed_entity") + self._commit_changes_to_database("Rename entity.") + data = self._db_map.query(self._db_map.entity_sq).all() self.assertEqual(len(data), 2) - self.assertEqual(data[0].name, "renamed_object") + self.assertEqual(data[0].name, "renamed_entity") - def test_remove_object_class(self): - view = self._db_editor.ui.treeView_object + def test_remove_entity_class(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) class_index = model.index(0, 0, root_index) view.selectionModel().setCurrentIndex(class_index, QItemSelectionModel.ClearAndSelect) - _remove_object_class(view) + _remove_entity_class(view) self.assertEqual(model.rowCount(root_index), 0) - self._commit_changes_to_database("Remove object class.") - data = self._db_mngr.query(self._db_map, "object_class_sq") + self._commit_changes_to_database("Remove entity class.") + data = self._db_map.query(self._db_map.entity_class_sq).all() self.assertEqual(len(data), 0) - def test_remove_object(self): - view = self._db_editor.ui.treeView_object + def test_remove_entity(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) @@ -323,113 +385,35 @@ def test_remove_object(self): model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() - object_index = model.index(0, 0, class_index) - view.selectionModel().setCurrentIndex(object_index, QItemSelectionModel.ClearAndSelect) - _remove_object(view) - self.assertEqual(model.rowCount(class_index), 1) - object_index = model.index(0, 0, class_index) - self.assertEqual(object_index.data(), "object_2") - self._commit_changes_to_database("Remove object.") - data = self._db_mngr.query(self._db_map, "object_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "object_2") - - def _rename_object_class(self, class_name): - view = self._db_editor.ui.treeView_object - _edit_entity_tree_item({0: class_name}, view, "Edit...", EditObjectClassesDialog) - - def _rename_object(self, object_name): - view = self._db_editor.ui.treeView_object - _edit_entity_tree_item({0: object_name}, view, "Edit...", EditObjectsDialog) - - -class TestRelationshipTreeViewWithInitiallyEmptyDatabase(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - - def tearDown(self): - self._common_tear_down() - - def test_empty_view(self): - view = self._db_editor.ui.treeView_relationship - model = view.model() - root_index = model.index(0, 0) - self.assertEqual(model.rowCount(root_index), 0) - self.assertEqual(model.columnCount(root_index), 2) - self.assertEqual(root_index.data(), "root") - self.assertEqual(model.headerData(0, Qt.Orientation.Horizontal), "name") - self.assertEqual(model.headerData(1, Qt.Orientation.Horizontal), "database") - - def test_add_relationship_class(self): - add_object_class(self._db_editor.ui.treeView_object, "an_object_class") - view = self._db_editor.ui.treeView_relationship - model = view.model() - root_index = model.index(0, 0) - view._context_item = model.item_from_index(root_index) - self._add_relationship_class("a_relationship_class", ["an_object_class"]) - class_index = model.index(0, 0, root_index) - self.assertEqual(model.rowCount(class_index), 0) - self.assertEqual(class_index.data(), "a_relationship_class") - class_database_index = model.index(0, 1, root_index) - self.assertEqual(class_database_index.data(), "database") - self._commit_changes_to_database("Add object and relationship classes.") - data = self._db_mngr.query(self._db_map, "wide_relationship_class_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "a_relationship_class") - self.assertEqual(data[0].object_class_name_list, "an_object_class") - - def test_add_relationship(self): - object_tree_view = self._db_editor.ui.treeView_object - add_object_class(object_tree_view, "an_object_class") - add_object(object_tree_view, "an_object") - view = self._db_editor.ui.treeView_relationship - model = view.model() - root_index = model.index(0, 0) - view._context_item = model.item_from_index(root_index) - self._add_relationship_class("a_relationship_class", ["an_object_class"]) - class_index = model.index(0, 0, root_index) - view._context_item = model.item_from_index(class_index) - self._add_relationship("a_relationship", ["an_object"]) - if model.canFetchMore(class_index): - model.fetchMore(class_index) + entity_index = model.index(0, 0, class_index) + view.selectionModel().setCurrentIndex(entity_index, QItemSelectionModel.ClearAndSelect) + _remove_entity(view) + while model.rowCount(class_index) != 1: QApplication.processEvents() self.assertEqual(model.rowCount(class_index), 1) - relationship_index = model.index(0, 0, class_index) - self.assertEqual(model.rowCount(relationship_index), 0) - self.assertEqual(relationship_index.data(), "an_object") - relationship_database_index = model.index(0, 1, class_index) - self.assertEqual(relationship_database_index.data(), "database") - self._commit_changes_to_database("Add an object and a relationship.") - data = self._db_mngr.query(self._db_map, "wide_relationship_sq") + entity_index = model.index(0, 0, class_index) + self.assertEqual(entity_index.data(), "entity_2") + self._commit_changes_to_database("Remove entity.") + data = self._db_map.query(self._db_map.entity_sq).all() self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "a_relationship") - self.assertEqual(data[0].object_name_list, "an_object") + self.assertEqual(data[0].name, "entity_2") - def _add_relationship_class(self, class_name, object_class_names): - item_names = {i: name for i, name in enumerate(object_class_names)} - item_names[len(object_class_names)] = class_name - add_entity_tree_item( - item_names, - self._db_editor.ui.treeView_relationship, - "Add relationship classes", - AddRelationshipClassesDialog, - ) + def _rename_entity_class(self, class_name): + view = self._db_editor.ui.treeView_entity + _edit_entity_tree_item({0: class_name}, view, "Edit...", EditEntityClassesDialog) - def _add_relationship(self, relationship_name, object_names): - item_names = {i: name for i, name in enumerate(object_names)} - item_names[len(object_names)] = relationship_name - add_entity_tree_item( - item_names, self._db_editor.ui.treeView_relationship, "Add relationships", AddRelationshipsDialog - ) + def _rename_entity(self, entity_name): + view = self._db_editor.ui.treeView_entity + _edit_entity_tree_item({0: entity_name}, view, "Edit...", EditEntitiesDialog) -class TestRelationshipTreeViewWithExistingData(TestBase): +class TestEntityTreeViewWithExistingMultidimensionalEntities(TestBase): def setUp(self): self._temp_dir = TemporaryDirectory() url = "sqlite:///" + os.path.join(self._temp_dir.name, "test_database.sqlite") db_map = DatabaseMapping(url, create=True) - import_object_classes(db_map, ("object_class_1", "object_class_2")) - import_objects( + import_entity_classes(db_map, (("object_class_1",), ("object_class_2",))) + import_entities( db_map, ( ("object_class_1", "object_11"), @@ -438,17 +422,17 @@ def setUp(self): ("object_class_2", "object_22"), ), ) - import_relationship_classes(db_map, (("relationship_class", ("object_class_1", "object_class_2")),)) - import_relationships( + import_entity_classes(db_map, (("relationship_class", ("object_class_1", "object_class_2")),)) + import_entities( db_map, (("relationship_class", ("object_11", "object_21")), ("relationship_class", ("object_11", "object_22"))), ) db_map.commit_session("Add relationships.") - db_map.connection.close() + db_map.close() self._common_setup(url, create=False) - model = self._db_editor.ui.treeView_relationship.model() + model = self._db_editor.ui.treeView_entity.model() root_index = model.index(0, 0) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: # Wait for fetching to finish. QApplication.processEvents() @@ -457,156 +441,193 @@ def tearDown(self): self._temp_dir.cleanup() def test_database_contents_shown_correctly(self): - view = self._db_editor.ui.treeView_relationship + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() self.assertEqual(model.columnCount(root_index), 2) self.assertEqual(root_index.data(), "root") self.assertEqual(model.headerData(0, Qt.Orientation.Horizontal), "name") self.assertEqual(model.headerData(1, Qt.Orientation.Horizontal), "database") - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() self.assertEqual(model.columnCount(class_index), 2) self.assertEqual(class_index.data(), "relationship_class") database_index = model.index(0, 1, root_index) - self.assertEqual(database_index.data(), "database") - relationship_index = model.index(0, 0, class_index) - self.assertEqual(model.rowCount(relationship_index), 0) - self.assertEqual(model.columnCount(relationship_index), 2) - self.assertEqual(relationship_index.data(), "object_11 ǀ object_21") + self.assertEqual(database_index.data(), self.db_codename) + entity_ndex = model.index(0, 0, class_index) + self.assertEqual(model.rowCount(entity_ndex), 0) + self.assertEqual(model.columnCount(entity_ndex), 2) + self.assertEqual(entity_ndex.data(), "object_11 ǀ object_21") database_index = model.index(0, 1, class_index) - self.assertEqual(database_index.data(), "database") - relationship_index = model.index(1, 0, class_index) - self.assertEqual(model.rowCount(relationship_index), 0) - self.assertEqual(model.columnCount(relationship_index), 2) - self.assertEqual(relationship_index.data(), "object_11 ǀ object_22") + self.assertEqual(database_index.data(), self.db_codename) + entity_ndex = model.index(1, 0, class_index) + self.assertEqual(model.rowCount(entity_ndex), 0) + self.assertEqual(model.columnCount(entity_ndex), 2) + self.assertEqual(entity_ndex.data(), "object_11 ǀ object_22") database_index = model.index(1, 1, class_index) - self.assertEqual(database_index.data(), "database") + self.assertEqual(database_index.data(), self.db_codename) - def test_rename_relationship_class(self): - view = self._db_editor.ui.treeView_relationship + def test_rename_multidimensional_entity_class(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) view.setCurrentIndex(class_index) - self._rename_relationship_class("renamed_class") - class_index = model.index(0, 0, root_index) + self._rename_class("renamed_class") + class_index = model.index(2, 0, root_index) self.assertEqual(class_index.data(), "renamed_class") self._commit_changes_to_database("Rename relationship class.") - data = self._db_mngr.query(self._db_map, "wide_relationship_class_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "renamed_class") + entity_class = ( + self._db_map.query(self._db_map.entity_class_sq) + .filter(self._db_map.entity_class_sq.c.name == "renamed_class") + .one() + ) + self.assertIsNotNone(entity_class) + self.assertEqual(entity_class.name, "renamed_class") - def test_rename_relationship(self): - view = self._db_editor.ui.treeView_relationship + def test_rename_multidimensional_entity(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() - relationship_index = model.index(0, 0, class_index) - view.setCurrentIndex(relationship_index) - self._rename_relationship("renamed_relationship") + entity_index = model.index(0, 0, class_index) + view.setCurrentIndex(entity_index) + self._rename_entity("renamed_relationship") + QApplication.processEvents() # Fixes a "silent" Traceback. self._commit_changes_to_database("Rename relationship.") - data = self._db_mngr.query(self._db_map, "wide_relationship_sq") + class_id = ( + self._db_map.query(self._db_map.entity_class_sq) + .filter(self._db_map.entity_class_sq.c.name == "relationship_class") + .one() + .id + ) + data = ( + self._db_map.query(self._db_map.wide_entity_sq) + .filter(self._db_map.wide_entity_sq.c.class_id == class_id) + .all() + ) self.assertEqual(len(data), 2) names = {i.name for i in data} - self.assertEqual(names, {"renamed_relationship", "relationship_class_object_11__object_22"}) + self.assertEqual(names, {"renamed_relationship", "object_11__object_22"}) - def test_modify_relationships_objects(self): - view = self._db_editor.ui.treeView_relationship + def test_modify_entitys_elements(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) + self.assertEqual(model.item_from_index(class_index).display_data, "relationship_class") model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() - relationship_index = model.index(0, 0, class_index) - view.setCurrentIndex(relationship_index) - _edit_entity_tree_item({0: "object_12"}, view, "Edit...", EditRelationshipsDialog) - self.assertEqual(relationship_index.data(), "object_12 ǀ object_21") + entity_index = model.index(0, 0, class_index) + view.setCurrentIndex(entity_index) + _edit_entity_tree_item({0: "object_12"}, view, "Edit...", EditEntitiesDialog) + QApplication.processEvents() # Fixes "silent" Traceback. + self.assertEqual(entity_index.data(), "object_12 ǀ object_21") self._commit_changes_to_database("Change relationship's objects.") - data = self._db_mngr.query(self._db_map, "wide_relationship_sq") - self.assertEqual(len(data), 2) - objects = {i.object_name_list for i in data} + class_id = ( + self._db_map.query(self._db_map.entity_class_sq) + .filter(self._db_map.entity_class_sq.c.name == "relationship_class") + .one() + .id + ) + data = self._db_map.query(self._db_map.wide_entity_sq).all() + self.assertEqual(len(data), 6) + objects = {i.element_name_list for i in data if i.class_id == class_id} self.assertEqual(objects, {"object_12,object_21", "object_11,object_22"}) - def test_remove_relationship_class(self): - view = self._db_editor.ui.treeView_relationship + def test_remove_multidimensional_entity_class(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) + self.assertEqual(model.item_from_index(class_index).display_data, "relationship_class") view.selectionModel().setCurrentIndex(class_index, QItemSelectionModel.ClearAndSelect) - self._remove_relationship_class() - self.assertEqual(model.rowCount(root_index), 0) + self._remove_class() + self.assertEqual(model.rowCount(root_index), 2) self._commit_changes_to_database("Remove relationship class.") - data = self._db_mngr.query(self._db_map, "wide_relationship_class_sq") - self.assertEqual(len(data), 0) + data = self._db_map.query(self._db_map.wide_entity_class_sq).all() + self.assertEqual(len(data), 2) + self.assertEqual({i.name for i in data}, {"object_class_1", "object_class_2"}) - def test_remove_relationship(self): - view = self._db_editor.ui.treeView_relationship + def test_remove_multidimensional_entity(self): + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) + self.assertEqual(model.item_from_index(class_index).display_data, "relationship_class") model.fetchMore(class_index) while model.rowCount(class_index) != 2: QApplication.processEvents() - relationship_index = model.index(0, 0, class_index) - view.selectionModel().setCurrentIndex(relationship_index, QItemSelectionModel.ClearAndSelect) - self._remove_relationship() - self.assertEqual(model.rowCount(class_index), 1) + entity_index = model.index(0, 0, class_index) + view.selectionModel().setCurrentIndex(entity_index, QItemSelectionModel.ClearAndSelect) + self._remove_entity() + while model.rowCount(class_index) != 1: + QApplication.processEvents() self._commit_changes_to_database("Remove relationship.") - data = self._db_mngr.query(self._db_map, "wide_relationship_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "relationship_class_object_11__object_22") + class_id = ( + self._db_map.query(self._db_map.entity_class_sq) + .filter(self._db_map.entity_class_sq.c.name == "relationship_class") + .one() + .id + ) + record = ( + self._db_map.query(self._db_map.wide_entity_sq) + .filter(self._db_map.wide_entity_sq.c.class_id == class_id) + .one() + ) + self.assertEqual(record.name, "object_11__object_22") - def test_removing_object_class_removes_corresponding_relationship_class(self): - object_tree_view = self._db_editor.ui.treeView_object + def test_removing_dimension_class_removes_corresponding_multidimensional_entity_class(self): + object_tree_view = self._db_editor.ui.treeView_entity object_model = object_tree_view.model() root_index = object_model.index(0, 0) object_model.fetchMore(root_index) - while object_model.rowCount(root_index) != 2: + while object_model.rowCount(root_index) != 3: QApplication.processEvents() class_index = object_model.index(0, 0, root_index) object_tree_view.selectionModel().setCurrentIndex(class_index, QItemSelectionModel.ClearAndSelect) - _remove_object_class(object_tree_view) - view = self._db_editor.ui.treeView_relationship + _remove_entity_class(object_tree_view) + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) QApplication.processEvents() - self.assertEqual(model.rowCount(root_index), 0) + self.assertEqual(model.rowCount(root_index), 1) self._commit_changes_to_database("Remove object class.") - data = self._db_mngr.query(self._db_map, "wide_relationship_class_sq") - self.assertEqual(len(data), 0) + data = self._db_map.query(self._db_map.wide_entity_class_sq).all() + self.assertEqual(len(data), 1) + self.assertEqual({i.name for i in data}, {"object_class_2"}) - def test_removing_object_removes_corresponding_relationship(self): - object_tree_view = self._db_editor.ui.treeView_object + def test_removing_element_removes_corresponding_entity(self): + object_tree_view = self._db_editor.ui.treeView_entity object_model = object_tree_view.model() root_index = object_model.index(0, 0) object_model.fetchMore(root_index) - while object_model.rowCount(root_index) != 2: + while object_model.rowCount(root_index) != 3: QApplication.processEvents() class_index = object_model.index(1, 0, root_index) self.assertEqual(class_index.data(), "object_class_2") @@ -616,38 +637,38 @@ def test_removing_object_removes_corresponding_relationship(self): object_index = object_model.index(0, 0, class_index) self.assertEqual(object_index.data(), "object_21") object_tree_view.selectionModel().setCurrentIndex(object_index, QItemSelectionModel.ClearAndSelect) - _remove_object(object_tree_view) - view = self._db_editor.ui.treeView_relationship + _remove_entity(object_tree_view) + view = self._db_editor.ui.treeView_entity model = view.model() root_index = model.index(0, 0) model.fetchMore(root_index) - while model.rowCount(root_index) != 1: + while model.rowCount(root_index) != 3: QApplication.processEvents() - class_index = model.index(0, 0, root_index) + class_index = model.index(2, 0, root_index) model.fetchMore(class_index) while model.rowCount(class_index) != 1: QApplication.processEvents() - relationship_index = model.index(0, 0, class_index) - self.assertEqual(relationship_index.data(), "object_11 ǀ object_22") + entity_index = model.index(0, 0, class_index) + self.assertEqual(entity_index.data(), "object_11 ǀ object_22") self._commit_changes_to_database("Remove object.") - data = self._db_mngr.query(self._db_map, "wide_relationship_sq") - self.assertEqual(len(data), 1) - self.assertEqual(data[0].name, "relationship_class_object_11__object_22") + data = self._db_map.query(self._db_map.entity_sq).all() + self.assertEqual(len(data), 4) + self.assertEqual({i.name for i in data}, {"object_11", "object_12", "object_22", "object_11__object_22"}) - def _rename_relationship_class(self, class_name): - view = self._db_editor.ui.treeView_relationship - _edit_entity_tree_item({0: class_name}, view, "Edit...", EditRelationshipClassesDialog) + def _rename_class(self, class_name): + view = self._db_editor.ui.treeView_entity + _edit_entity_tree_item({0: class_name}, view, "Edit...", EditEntityClassesDialog) - def _rename_relationship(self, name): - view = self._db_editor.ui.treeView_relationship - _edit_entity_tree_item({2: name}, view, "Edit...", EditRelationshipsDialog) + def _rename_entity(self, name): + view = self._db_editor.ui.treeView_entity + _edit_entity_tree_item({2: name}, view, "Edit...", EditEntitiesDialog) - def _remove_relationship_class(self): - view = self._db_editor.ui.treeView_relationship + def _remove_class(self): + view = self._db_editor.ui.treeView_entity _remove_entity_tree_item(view, "Remove...", RemoveEntitiesDialog) - def _remove_relationship(self): - view = self._db_editor.ui.treeView_relationship + def _remove_entity(self): + view = self._db_editor.ui.treeView_entity _remove_entity_tree_item(view, "Remove...", RemoveEntitiesDialog) @@ -656,9 +677,6 @@ def setUp(self): self._common_setup("sqlite://", create=True) self._edits = _ParameterValueListEdits(self._db_editor.ui.treeView_parameter_value_list) - def tearDown(self): - self._common_tear_down() - def test_empty_tree_has_correct_contents(self): model = self._db_editor.ui.treeView_parameter_value_list.model() root_index = model.index(0, 0) @@ -697,7 +715,10 @@ def test_add_list_then_remove_it(self): def test_add_two_parameter_value_list_values(self): list_name_index = self._edits.append_value_list(self._db_mngr, "a_value_list") view = self._db_editor.ui.treeView_parameter_value_list + view.expandAll() model = view.model() + root_index = model.index(0, 0) + self.assertEqual(model.rowCount(root_index), 2) value_index1 = model.index(0, 0, list_name_index) self._edits.view_editor.write_to_index(view, value_index1, "value_1") self.assertEqual(model.index(0, 0, list_name_index).data(), "value_1") @@ -708,10 +729,10 @@ def test_add_two_parameter_value_list_values(self): QApplication.processEvents() self.assertEqual(model.index(1, 0, list_name_index).data(), "value_2") self._commit_changes_to_database("Add parameter value list.") - data = self._db_mngr.query(self._db_map, "parameter_value_list_sq") + data = self._db_map.query(self._db_map.parameter_value_list_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "a_value_list") - data = self._db_mngr.query(self._db_map, "list_value_sq") + data = self._db_map.query(self._db_map.list_value_sq).all() self.assertEqual(len(data), 2) for i, expected_value in enumerate(("value_1", "value_2")): self.assertEqual(from_database(data[i].value), expected_value) @@ -724,7 +745,7 @@ def setUp(self): db_map = DatabaseMapping(url, create=True) import_parameter_value_lists(db_map, (("value_list_1", "value_1"), ("value_list_1", "value_2"))) db_map.commit_session("Add parameter value list.") - db_map.connection.close() + db_map.close() self._common_setup(url, create=False) view = self._db_editor.ui.treeView_parameter_value_list @@ -764,6 +785,7 @@ def test_remove_value(self): value_index = model.index(0, 0, list_name_index) view.selectionModel().setCurrentIndex(value_index, QItemSelectionModel.ClearAndSelect) view.remove_selected() + qApp.processEvents() root_index = model.index(0, 0) self.assertEqual(model.rowCount(root_index), 2) list_name_index = model.index(0, 0, root_index) @@ -774,10 +796,10 @@ def test_remove_value(self): list_name_index = model.index(1, 0, root_index) self.assertEqual(list_name_index.data(), "Type new list name here...") self._commit_changes_to_database("Remove parameter value list value.") - data = self._db_mngr.query(self._db_map, "parameter_value_list_sq") + data = self._db_map.query(self._db_map.parameter_value_list_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "value_list_1") - data = self._db_mngr.query(self._db_map, "list_value_sq") + data = self._db_map.query(self._db_map.list_value_sq).all() self.assertEqual(len(data), 1) self.assertEqual(from_database(data[0].value, data[0].type), "value_2") @@ -794,7 +816,7 @@ def test_remove_list(self): self.assertEqual(model.rowCount(list_name_index), 0) self.assertEqual(list_name_index.data(), "Type new list name here...") self._commit_changes_to_database("Remove parameter value list.") - data = self._db_mngr.query(self._db_map, "parameter_value_list_sq") + data = self._db_map.query(self._db_map.parameter_value_list_sq).all() self.assertEqual(len(data), 0) def test_change_value(self): @@ -807,10 +829,10 @@ def test_change_value(self): self.assertEqual(model.index(0, 0, list_name_index).data(), "new_value") self.assertEqual(model.index(1, 0, list_name_index).data(), "value_2") self._commit_changes_to_database("Update parameter value list value.") - data = self._db_mngr.query(self._db_map, "parameter_value_list_sq") + data = self._db_map.query(self._db_map.parameter_value_list_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "value_list_1") - data = self._db_mngr.query(self._db_map, "list_value_sq") + data = self._db_map.query(self._db_map.list_value_sq).all() self.assertEqual(len(data), 2) for i, expected_value in enumerate(("new_value", "value_2")): self.assertEqual(from_database(data[i].value, data[i].type), expected_value) @@ -831,331 +853,14 @@ def test_rename_list(self): list_name_index = model.index(1, 0, root_index) self.assertEqual(list_name_index.data(), "Type new list name here...") self._commit_changes_to_database("Rename parameter value list.") - data = self._db_mngr.query(self._db_map, "parameter_value_list_sq") + data = self._db_map.query(self._db_map.parameter_value_list_sq).all() self.assertEqual(len(data), 1) self.assertEqual(data[0].name, "new_list_name") - data = self._db_mngr.query(self._db_map, "list_value_sq") + data = self._db_map.query(self._db_map.list_value_sq).all() self.assertEqual(len(data), 2) for i, expected_value in enumerate(("value_1", "value_2")): self.assertEqual(from_database(data[i].value), expected_value) -class TestToolFeatureTreeViewWithInitiallyEmptyDatabase(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - self._value_list_edits = _ParameterValueListEdits(self._db_editor.ui.treeView_parameter_value_list) - - def tearDown(self): - self._common_tear_down() - - def test_empty_tree_has_correct_contents(self): - model = self._db_editor.ui.treeView_tool_feature.model() - db_index = model.index(0, 0) - self.assertTrue(db_index.isValid()) - self.assertEqual(model.rowCount(db_index), 2) - feature_root_index = model.index(0, 0, db_index) - self.assertEqual(feature_root_index.data(), "feature") - self.assertEqual(model.rowCount(feature_root_index), 1) - feature_index = model.index(0, 0, feature_root_index) - self.assertEqual(feature_index.data(), "Enter new feature here...") - self.assertEqual(model.rowCount(feature_index), 0) - tool_root_index = model.index(1, 0, db_index) - self.assertEqual(tool_root_index.data(), "tool") - self.assertEqual(model.rowCount(tool_root_index), 1) - tool_index = model.index(0, 0, tool_root_index) - self.assertEqual(tool_index.data(), "Type new tool name here...") - self.assertEqual(model.rowCount(tool_index), 0) - - def test_add_feature_without_parameter_definitions_opens_error_box(self): - tree_view = self._db_editor.ui.treeView_tool_feature - model = tree_view.model() - db_index = model.index(0, 0) - feature_root_index = model.index(0, 0, db_index) - feature_index = model.index(0, 0, feature_root_index) - view_edit = EditorDelegateMocking() - with mock.patch.object(self._db_editor, "msg_error") as mock_error: - view_edit.try_to_edit_index(tree_view, feature_index) - mock_error.emit.assert_called_once_with( - "There aren't any listed parameter definitions to create features from." - ) - - def test_add_feature(self): - self._add_parameter_with_value_list() - self._add_feature() - model = self._db_editor.ui.treeView_tool_feature.model() - db_index = model.index(0, 0) - feature_root_index = model.index(0, 0, db_index) - self.assertEqual(model.rowCount(feature_root_index), 2) - self.assertEqual(model.index(0, 0, feature_root_index).data(), "my_object_class/my_parameter") - self.assertEqual(model.index(1, 0, feature_root_index).data(), "Enter new feature here...") - - def test_add_tool(self): - self._add_tool() - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - db_index = model.index(0, 0) - tool_root_index = model.index(1, 0, db_index) - self.assertEqual(model.rowCount(tool_root_index), 2) - tool_index = model.index(0, 0, tool_root_index) - self.assertEqual(tool_index.data(), "my_tool") - self.assertEqual(model.index(1, 0, tool_root_index).data(), "Type new tool name here...") - self.assertEqual(model.rowCount(tool_index), 1) - tool_feature_root_index = model.index(0, 0, tool_index) - self.assertEqual(tool_feature_root_index.data(), "tool_feature") - self.assertEqual(model.rowCount(tool_feature_root_index), 1) - tool_feature_index = model.index(0, 0, tool_feature_root_index) - self.assertEqual(tool_feature_index.data(), "Type tool feature name here...") - self.assertEqual(model.rowCount(tool_feature_index), 0) - - def test_add_tool_feature(self): - self._add_parameter_with_value_list() - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - for item in model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() - self._add_feature() - self._add_tool() - self._add_tool_feature() - db_index = model.index(0, 0) - tool_root_index = model.index(1, 0, db_index) - tool_index = model.index(0, 0, tool_root_index) - tool_feature_root_index = model.index(0, 0, tool_index) - self.assertEqual(model.rowCount(tool_feature_root_index), 2) - tool_feature_index = model.index(0, 0, tool_feature_root_index) - self.assertEqual(tool_feature_index.data(), "my_object_class/my_parameter") - self.assertEqual(model.rowCount(tool_feature_index), 2) - self.assertEqual(model.index(1, 0, tool_feature_root_index).data(), "Type tool feature name here...") - self.assertEqual(model.index(0, 0, tool_feature_index).data(), "required: no") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(method_root_index.data(), "tool_feature_method") - self.assertEqual(model.rowCount(method_root_index), 1) - method_index = model.index(0, 0, method_root_index) - self.assertEqual(method_index.data(), "Enter new method here...") - self.assertEqual(model.rowCount(method_index), 0) - - def test_add_tool_feature_method(self): - self._add_parameter_with_value_list() - self._add_feature() - self._add_tool() - self._add_tool_feature() - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - for item in model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() - db_index = model.index(0, 0) - tool_root_index = model.index(1, 0, db_index) - tool_index = model.index(0, 0, tool_root_index) - tool_feature_root_index = model.index(0, 0, tool_index) - tool_feature_index = model.index(0, 0, tool_feature_root_index) - method_root_index = model.index(1, 0, tool_feature_index) - method_index = model.index(0, 0, method_root_index) - view_edit = EditorDelegateMocking() - view_edit.write_to_index(view, method_index, "2.3") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(model.rowCount(method_root_index), 2) - method_index = model.index(0, 0, method_root_index) - self.assertEqual(method_index.data(), "2.3") - self.assertEqual(model.rowCount(method_index), 0) - self.assertEqual(model.index(1, 0, method_root_index).data(), "Enter new method here...") - - def _add_parameter_with_value_list(self): - self._value_list_edits.append_value_list(self._db_mngr, "my_value_list") - self._value_list_edits.append_value(self._db_mngr, "my_value_list", 2.3) - object_tree_view = self._db_editor.ui.treeView_object - add_object_class(object_tree_view, "my_object_class") - object_parameter_definition_view = self._db_editor.ui.tableView_object_parameter_definition - _append_table_row(object_parameter_definition_view, ["my_object_class", "my_parameter", "my_value_list"]) - - def _add_feature(self): - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - db_index = model.index(0, 0) - feature_root_index = model.index(0, 0, db_index) - feature_index = model.index(0, 0, feature_root_index) - view_edit = EditorDelegateMocking() - view_edit.write_to_index(view, feature_index, "my_object_class/my_parameter") - for item in model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() - - def _add_tool(self): - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - db_index = model.index(0, 0) - tool_root_index = model.index(1, 0, db_index) - tool_index = model.index(0, 0, tool_root_index) - view_edit = EditorDelegateMocking() - view_edit.write_to_index(view, tool_index, "my_tool") - for item in model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() - - def _add_tool_feature(self): - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - db_index = model.index(0, 0) - tool_root_index = model.index(1, 0, db_index) - tool_index = model.index(0, 0, tool_root_index) - tool_feature_root_index = model.index(0, 0, tool_index) - tool_feature_index = model.index(0, 0, tool_feature_root_index) - view_edit = EditorDelegateMocking() - view_edit.write_to_index(view, tool_feature_index, "my_object_class/my_parameter") - for item in model.visit_all(): - if item.can_fetch_more(): - item.fetch_more() - - -class TestToolFeatureTreeViewWithExistingData(TestBase): - def setUp(self): - self._temp_dir = TemporaryDirectory() - url = "sqlite:///" + os.path.join(self._temp_dir.name, "test_database.sqlite") - db_map = DatabaseMapping(url, create=True) - import_parameter_value_lists( - db_map, (("value_list_1", 5.0), ("value_list_1", 2.3), ("value_list_2", "law_of_fives")) - ) - import_object_classes(db_map, ("my_object_class",)) - import_object_parameters( - db_map, - ( - ("my_object_class", "parameter_1", None, "value_list_1"), - ("my_object_class", "parameter_2", None, "value_list_2"), - ), - ) - import_features(db_map, (("my_object_class", "parameter_1"), ("my_object_class", "parameter_2"))) - import_tools(db_map, ("tool_1", "tool_2")) - import_tool_features( - db_map, - ( - ("tool_1", "my_object_class", "parameter_1"), - ("tool_1", "my_object_class", "parameter_2"), - ("tool_2", "my_object_class", "parameter_1"), - ("tool_2", "my_object_class", "parameter_2"), - ), - ) - import_tool_feature_methods( - db_map, - ( - ("tool_1", "my_object_class", "parameter_1", 5.0), - ("tool_1", "my_object_class", "parameter_1", 2.3), - ("tool_1", "my_object_class", "parameter_2", "law_of_fives"), - ("tool_2", "my_object_class", "parameter_1", 5.0), - ("tool_2", "my_object_class", "parameter_1", 2.3), - ("tool_2", "my_object_class", "parameter_2", "law_of_fives"), - ), - ) - db_map.commit_session("Add tool feature methods.") - db_map.connection.close() - self._common_setup(url, create=False) - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - # Fetch the entire model - db_index = model.index(0, 0) - feature_root_index = model.index(0, 0, db_index) - while model.rowCount(feature_root_index) != 3: - QApplication.processEvents() - tool_root_index = model.index(1, 0, db_index) - while model.rowCount(tool_root_index) != 3: - QApplication.processEvents() - tool_index = model.index(0, 0, tool_root_index) - tool_feature_root_index = model.index(0, 0, tool_index) - while model.rowCount(tool_feature_root_index) != 3: - model.fetchMore(tool_feature_root_index) - QApplication.processEvents() - tool_feature_index = model.index(0, 0, tool_feature_root_index) - method_root_index = model.index(1, 0, tool_feature_index) - while model.rowCount(method_root_index) != 3: - model.fetchMore(method_root_index) - QApplication.processEvents() - tool_feature_index = model.index(1, 0, tool_feature_root_index) - method_root_index = model.index(1, 0, tool_feature_index) - while model.rowCount(method_root_index) != 2: - model.fetchMore(method_root_index) - QApplication.processEvents() - tool_index = model.index(1, 0, tool_root_index) - tool_feature_root_index = model.index(0, 0, tool_index) - while model.rowCount(tool_feature_root_index) != 3: - model.fetchMore(tool_feature_root_index) - QApplication.processEvents() - tool_feature_index = model.index(0, 0, tool_feature_root_index) - method_root_index = model.index(1, 0, tool_feature_index) - while model.rowCount(method_root_index) != 3: - model.fetchMore(method_root_index) - QApplication.processEvents() - tool_feature_index = model.index(1, 0, tool_feature_root_index) - method_root_index = model.index(1, 0, tool_feature_index) - while model.rowCount(method_root_index) != 2: - model.fetchMore(method_root_index) - QApplication.processEvents() - - def tearDown(self): - self._common_tear_down() - self._temp_dir.cleanup() - - def test_tree_has_correct_initial_contents(self): - view = self._db_editor.ui.treeView_tool_feature - model = view.model() - db_index = model.index(0, 0) - feature_root_index = model.index(0, 0, db_index) - self.assertEqual(model.rowCount(feature_root_index), 3) - self.assertEqual(model.index(0, 0, feature_root_index).data(), "my_object_class/parameter_1") - self.assertEqual(model.index(1, 0, feature_root_index).data(), "my_object_class/parameter_2") - self.assertEqual(model.index(2, 0, feature_root_index).data(), "Enter new feature here...") - tool_root_index = model.index(1, 0, db_index) - self.assertEqual(model.rowCount(tool_root_index), 3) - self.assertEqual(model.index(0, 0, tool_root_index).data(), "tool_1") - self.assertEqual(model.index(1, 0, tool_root_index).data(), "tool_2") - self.assertEqual(model.index(2, 0, tool_root_index).data(), "Type new tool name here...") - tool_index = model.index(0, 0, tool_root_index) - self.assertEqual(model.rowCount(tool_index), 1) - tool_feature_root_index = model.index(0, 0, tool_index) - self.assertEqual(model.rowCount(tool_feature_root_index), 3) - self.assertEqual(model.index(0, 0, tool_feature_root_index).data(), "my_object_class/parameter_1") - self.assertEqual(model.index(1, 0, tool_feature_root_index).data(), "my_object_class/parameter_2") - self.assertEqual(model.index(2, 0, tool_feature_root_index).data(), "Type tool feature name here...") - tool_feature_index = model.index(0, 0, tool_feature_root_index) - self.assertEqual(model.rowCount(tool_feature_index), 2) - self.assertEqual(model.index(0, 0, tool_feature_index).data(), "required: no") - self.assertEqual(model.index(1, 0, tool_feature_index).data(), "tool_feature_method") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(model.rowCount(method_root_index), 3) - self.assertEqual(model.index(0, 0, method_root_index).data(), "2.3") - self.assertEqual(model.index(1, 0, method_root_index).data(), "5.0") - self.assertEqual(model.index(2, 0, method_root_index).data(), "Enter new method here...") - tool_feature_index = model.index(1, 0, tool_feature_root_index) - self.assertEqual(model.rowCount(tool_feature_index), 2) - self.assertEqual(model.index(0, 0, tool_feature_index).data(), "required: no") - self.assertEqual(model.index(1, 0, tool_feature_index).data(), "tool_feature_method") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(model.rowCount(method_root_index), 2) - self.assertEqual(model.index(0, 0, method_root_index).data(), "law_of_fives") - self.assertEqual(model.index(1, 0, method_root_index).data(), "Enter new method here...") - tool_index = model.index(1, 0, tool_root_index) - self.assertEqual(model.rowCount(tool_index), 1) - tool_feature_root_index = model.index(0, 0, tool_index) - self.assertEqual(model.rowCount(tool_feature_root_index), 3) - self.assertEqual(model.index(0, 0, tool_feature_root_index).data(), "my_object_class/parameter_1") - self.assertEqual(model.index(1, 0, tool_feature_root_index).data(), "my_object_class/parameter_2") - self.assertEqual(model.index(2, 0, tool_feature_root_index).data(), "Type tool feature name here...") - tool_feature_index = model.index(0, 0, tool_feature_root_index) - self.assertEqual(model.rowCount(tool_feature_index), 2) - self.assertEqual(model.index(0, 0, tool_feature_index).data(), "required: no") - self.assertEqual(model.index(1, 0, tool_feature_index).data(), "tool_feature_method") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(model.rowCount(method_root_index), 3) - self.assertEqual(model.index(0, 0, method_root_index).data(), "2.3") - self.assertEqual(model.index(1, 0, method_root_index).data(), "5.0") - self.assertEqual(model.index(2, 0, method_root_index).data(), "Enter new method here...") - tool_feature_index = model.index(1, 0, tool_feature_root_index) - self.assertEqual(model.rowCount(tool_feature_index), 2) - self.assertEqual(model.index(0, 0, tool_feature_index).data(), "required: no") - self.assertEqual(model.index(1, 0, tool_feature_index).data(), "tool_feature_method") - method_root_index = model.index(1, 0, tool_feature_index) - self.assertEqual(model.rowCount(method_root_index), 2) - self.assertEqual(model.index(0, 0, method_root_index).data(), "law_of_fives") - self.assertEqual(model.index(1, 0, method_root_index).data(), "Enter new method here...") - - -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_edit_or_remove_items_dialogs.py b/tests/spine_db_editor/widgets/test_edit_or_remove_items_dialogs.py new file mode 100644 index 000000000..fd7219496 --- /dev/null +++ b/tests/spine_db_editor/widgets/test_edit_or_remove_items_dialogs.py @@ -0,0 +1,88 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for edit_or_remove_items_dialogs module.""" +import unittest +from unittest import mock +from PySide6.QtCore import QItemSelectionModel +from PySide6.QtWidgets import QApplication +from spinetoolbox.spine_db_editor.widgets.edit_or_remove_items_dialogs import EditEntityClassesDialog +from tests.spine_db_editor.helpers import TestBase + + +class TestEditEntityClassesDialog(TestBase): + def test_pasting_gibberish_to_active_by_default_column_gives_false(self): + self._db_map.add_entity_class_item(name="Object") + entity_tree = self._db_editor.ui.treeView_entity + entity_model = entity_tree.model() + entity_model.root_item.fetch_more() + while not entity_model.root_item.children: + QApplication.processEvents() + self.assertEqual(len(entity_model.root_item.children), 1) + dialog = EditEntityClassesDialog(self._db_editor, self._db_mngr, entity_model.root_item.children) + model = dialog.model + self._assert_table_contents(model, [["Object", None, None, False, "TestEditEntityClassesDialog_db"]]) + dialog.table_view.selectionModel().setCurrentIndex( + model.index(0, 3), QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "true" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(dialog.table_view.paste()) + self._assert_table_contents(model, [["Object", None, None, True, "TestEditEntityClassesDialog_db"]]) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "GIBBERISH" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(dialog.table_view.paste()) + self._assert_table_contents(model, [["Object", None, None, False, "TestEditEntityClassesDialog_db"]]) + + def test_pasting_gibberish_to_display_icon_column_gives_none(self): + self._db_map.add_entity_class_item(name="Object") + entity_tree = self._db_editor.ui.treeView_entity + entity_model = entity_tree.model() + entity_model.root_item.fetch_more() + while not entity_model.root_item.children: + QApplication.processEvents() + self.assertEqual(len(entity_model.root_item.children), 1) + dialog = EditEntityClassesDialog(self._db_editor, self._db_mngr, entity_model.root_item.children) + model = dialog.model + self._assert_table_contents(model, [["Object", None, None, False, "TestEditEntityClassesDialog_db"]]) + dialog.table_view.selectionModel().setCurrentIndex( + model.index(0, 2), QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "23" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(dialog.table_view.paste()) + self._assert_table_contents(model, [["Object", None, 23, False, "TestEditEntityClassesDialog_db"]]) + mock_clipboard = mock.MagicMock() + mock_clipboard.text.return_value = "GIBBERISH" + with mock.patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(dialog.table_view.paste()) + self._assert_table_contents(model, [["Object", None, None, False, "TestEditEntityClassesDialog_db"]]) + + def _assert_table_contents(self, model, expected): + data_table = [] + for row in range(model.rowCount()): + row_data = [] + for column in range(model.columnCount()): + row_data.append(model.index(row, column).data()) + data_table.append(row_data) + self.assertEqual(data_table, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/test_mass_select-items_dialogs.py b/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py similarity index 66% rename from tests/spine_db_editor/widgets/test_mass_select-items_dialogs.py rename to tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py index c7e55cfd3..086371d20 100644 --- a/tests/spine_db_editor/widgets/test_mass_select-items_dialogs.py +++ b/tests/spine_db_editor/widgets/test_mass_select_items_dialogs.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -10,13 +11,11 @@ ###################################################################################################################### """Test for `mass_select_items_dialogs` module.""" - import unittest from unittest import mock - from PySide6.QtWidgets import QApplication, QDialogButtonBox -from spinetoolbox.helpers import signal_waiter +from spinedb_api.temp_id import TempId from spinetoolbox.spine_db_editor.widgets.mass_select_items_dialogs import MassRemoveItemsDialog from spinetoolbox.spine_db_manager import SpineDBManager @@ -41,12 +40,12 @@ def setUp(self): def tearDown(self): self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() def test_stored_state(self): - state = {"databases": {"database": True}, "items": {"object": True, "relationship": False}} + state = {"databases": {self._db_map: True}, "items": {"entity": True, "entity_class": True}} dialog = MassRemoveItemsDialog(None, self._db_mngr, self._db_map, stored_state=state) self.assertEqual( dialog._item_check_boxes_widget.checked_states(), @@ -54,53 +53,47 @@ def test_stored_state(self): "alternative": False, "entity_group": False, "entity_metadata": False, - "feature": False, + "list_value": False, "metadata": False, - "object": True, - "object_class": False, + "entity": True, + "entity_alternative": False, + "entity_class": True, "parameter_definition": False, "parameter_value": False, "parameter_value_list": False, "parameter_value_metadata": False, - "relationship": False, - "relationship_class": False, "scenario": False, "scenario_alternative": False, - "tool": False, - "tool_feature": False, - "tool_feature_method": False, + "superclass_subclass": False, }, ) - self.assertTrue(dialog._db_map_check_boxes[self._db_map].isChecked()) + self.assertTrue(dialog._database_check_boxes_widget._check_boxes[self._db_map].isChecked()) def test_purge_objects(self): - with signal_waiter(self._db_mngr.object_classes_added) as waiter: - self._db_mngr.add_object_classes({self._db_map: [{"name": "my_class"}]}) - waiter.wait() - with signal_waiter(self._db_mngr.objects_added) as waiter: - self._db_mngr.add_objects({self._db_map: [{"class_id": 1, "name": "my_object"}]}) - waiter.wait() + self._db_mngr.add_entity_classes({self._db_map: [{"name": "my_class"}]}) + classes = self._db_mngr.get_items(self._db_map, "entity_class") + class_id = classes[0]["id"] + self._db_mngr.add_entities({self._db_map: [{"class_id": class_id, "name": "my_object"}]}) + entities = [item._asdict() for item in self._db_mngr.get_items(self._db_map, "entity")] + self.assertEqual(len(entities), 1) + entity_id = entities[0]["id"] self.assertEqual( - self._db_mngr.get_items(self._db_map, "object"), + entities, [ { - 'class_id': 1, - 'class_name': 'my_class', - 'commit_id': None, - 'group_id': None, - 'id': 1, - 'name': 'my_object', - 'type_id': 1, + "class_id": class_id, + "description": None, + "id": entity_id, + "name": "my_object", + "element_id_list": (), } ], ) dialog = MassRemoveItemsDialog(None, self._db_mngr, self._db_map) - dialog._db_map_check_boxes[self._db_map].setChecked(True) - dialog._item_check_boxes_widget._item_check_boxes["object"].setChecked(True) - with signal_waiter(self._db_mngr.objects_removed) as waiter: - dialog._ui.button_box.button(QDialogButtonBox.StandardButton.Ok).click() - waiter.wait() - self.assertEqual(self._db_mngr.get_items(self._db_map, "object"), []) + dialog._database_check_boxes_widget._check_boxes[self._db_map].setChecked(True) + dialog._item_check_boxes_widget._item_check_boxes["entity"].setChecked(True) + dialog._ui.button_box.button(QDialogButtonBox.StandardButton.Ok).click() + self.assertEqual(self._db_mngr.get_items(self._db_map, "entity"), []) if __name__ == "__main__": diff --git a/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py new file mode 100644 index 000000000..486d6f3d5 --- /dev/null +++ b/tests/spine_db_editor/widgets/test_multi_spine_db_editor.py @@ -0,0 +1,42 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for SpineDBEditor classes.""" +from tempfile import TemporaryDirectory +from PySide6.QtCore import QPoint +from spinetoolbox.spine_db_editor.widgets.multi_spine_db_editor import MultiSpineDBEditor +from .spine_db_editor_test_base import DBEditorTestBase +from tests.mock_helpers import create_toolboxui_with_project, clean_up_toolbox, FakeDataStore + + +class TestMultiSpineDBEditor(DBEditorTestBase): + def setUp(self): + super().setUp() + self._temp_dir = TemporaryDirectory() + self._toolbox = create_toolboxui_with_project(self._temp_dir.name) + + def tearDown(self): + super().tearDown() + clean_up_toolbox(self._toolbox) + self._temp_dir.cleanup() + + def test_multi_spine_db_editor(self): + self.db_mngr.setParent(self._toolbox) + multieditor = MultiSpineDBEditor(self.db_mngr) + multieditor.add_new_tab() + self.assertEqual(1, multieditor.tab_widget.count()) + multieditor.make_context_menu(0) + multieditor.show_plus_button_context_menu(QPoint(0, 0)) + # Add fake data stores to project + self._toolbox.project()._project_items = {"a": FakeDataStore("a")} + multieditor.show_plus_button_context_menu(QPoint(0, 0)) + multieditor._take_tab(0) diff --git a/tests/spine_db_editor/widgets/test_scenario_generator.py b/tests/spine_db_editor/widgets/test_scenario_generator.py index 97b270f58..14bbbfc0e 100644 --- a/tests/spine_db_editor/widgets/test_scenario_generator.py +++ b/tests/spine_db_editor/widgets/test_scenario_generator.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -11,20 +12,12 @@ """Test for `scenario_generator` module.""" import unittest - from PySide6.QtCore import Qt - from spinetoolbox.spine_db_editor.widgets.scenario_generator import ScenarioGenerator -from .helpers import TestBase +from tests.spine_db_editor.helpers import TestBase class TestScenarioGenerator(TestBase): - def setUp(self): - self._common_setup("sqlite://", create=True) - - def tearDown(self): - self._common_tear_down() - def test_alternative_list_contains_alternatives(self): self._db_mngr.add_alternatives({self._db_map: [{"name": "alt1"}]}) alternatives = self._db_mngr.get_items(self._db_map, "alternative") @@ -76,5 +69,5 @@ def test_zero_padding_in_generated_scenario_names(self): ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/spine_db_editor/widgets/test_tabular_view_mixin.py b/tests/spine_db_editor/widgets/test_tabular_view_mixin.py new file mode 100644 index 000000000..1f7c91b76 --- /dev/null +++ b/tests/spine_db_editor/widgets/test_tabular_view_mixin.py @@ -0,0 +1,217 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for ``tabular_view_mixin`` module.""" +import itertools +import unittest +from unittest.mock import patch +from PySide6.QtCore import QItemSelectionModel +from PySide6.QtWidgets import QApplication +from spinedb_api import Map +from tests.mock_helpers import fetch_model +from tests.spine_db_editor.helpers import TestBase + + +class TestPivotHeaderDraggingAndDropping(TestBase): + def _add_entity_class_data(self): + data = { + "entity_classes": (("class1",),), + "parameter_definitions": (("class1", "parameter1"), ("class1", "parameter2")), + "entities": (("class1", "object1"), ("class1", "object2")), + "parameter_values": ( + ("class1", "object1", "parameter1", 1.0), + ("class1", "object2", "parameter1", 3.0), + ("class1", "object1", "parameter2", 5.0), + ("class1", "object2", "parameter2", 7.0), + ), + } + self._db_mngr.import_data({self._db_map: data}) + + def _add_entity_class_data_with_indexes_values(self): + data = { + "entity_classes": (("class1",),), + "parameter_definitions": (("class1", "parameter1"),), + "entities": (("class1", "object1"), ("class1", "object2")), + "parameter_values": ( + ( + "class1", + "object1", + "parameter1", + Map(["k1", "k2"], [Map(["q1", "q2"], [11.0, 111.0]), Map(["q1", "q2"], [22.0, 222.0])]), + ), + ( + "class1", + "object2", + "parameter1", + Map(["k1", "k2"], [Map(["q1", "q2"], [-11.0, -111.0]), Map(["q1", "q2"], [-22.0, -222.0])]), + ), + ), + } + self._db_mngr.import_data({self._db_map: data}) + + def _start(self): + get_item_exceptions = [] + + def guarded_get_item(db_map, item_type, id_): + try: + return db_map.get_item(item_type, id=id_) + except Exception as error: + get_item_exceptions.append(error) + return None + + with patch.object(self._db_mngr, "get_item") as get_item: + get_item.side_effect = guarded_get_item + object_class_index = self._db_editor.entity_tree_model.index(0, 0) + fetch_model(self._db_editor.entity_tree_model) + index = self._db_editor.entity_tree_model.index(0, 0, object_class_index) + self._db_editor._update_class_attributes(index) + with patch.object(self._db_editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: + mock_is_visible.return_value = True + self._db_editor.do_reload_pivot_table() + pivot_model = self._db_editor.pivot_table_model + pivot_model.beginResetModel() + pivot_model.endResetModel() + QApplication.processEvents() + self.assertEqual(get_item_exceptions, []) + + def _change_pivot_input_type(self, input_type): + for action in self._db_editor.pivot_action_group.actions(): + if action.text() == input_type: + with patch.object(self._db_editor.ui.dockWidget_pivot_table, "isVisible") as mock_is_visible: + mock_is_visible.return_value = True + action.trigger() + break + else: + raise RuntimeError(f"Unknown input type '{input_type}'.") + + def test_drag_and_drop_database_from_frozen_table(self): + self._add_entity_class_data() + self._start() + original_frozen_columns = tuple(self._db_editor.pivot_table_model.model.pivot_frozen) + frozen_table_header_widget = self._get_header_widget(self._db_editor.ui.frozen_table, "database") + self._drag_and_drop_header(frozen_table_header_widget, frozen_table_header_widget) + self.assertEqual(self._db_editor.pivot_table_model.model.pivot_frozen, original_frozen_columns) + + def test_purging_data_in_value_mode(self): + self._add_entity_class_data() + self._start() + pivot_model = self._db_editor.pivot_table_model + self.assertEqual(pivot_model.rowCount(), 5) + self._db_mngr.purge_items({self._db_map: ["alternative", "entity_class"]}) + self.assertEqual(pivot_model.rowCount(), 0) + self.assertEqual(self._db_editor.frozen_table_model.rowCount(), 1) + + def test_purging_data_in_value_mode_when_entity_class_is_frozen(self): + self._add_entity_class_data() + self._start() + database_header_widget = self._get_header_widget(self._db_editor.ui.frozen_table, "database") + class_header_widget = self._get_header_widget(self._db_editor.ui.pivot_table, "class1") + self._drag_and_drop_header(database_header_widget, class_header_widget) + self._select_frozen_row(1) + expected = [["alternative"], ["Base"]] + self._assert_model_data_equals(self._db_editor.frozen_table_model, expected) + expected = [ + [None, "parameter", "parameter1", "parameter2", None], + ["database", "class1", None, None, None], + ["TestPivotHeaderDraggingAndDropping_db", "object1", "1.0", "5.0", None], + ["TestPivotHeaderDraggingAndDropping_db", "object2", "3.0", "7.0", None], + ["TestPivotHeaderDraggingAndDropping_db", None, None, None, None], + ] + self._assert_model_data_equals(self._db_editor.pivot_table_model, expected) + frozen_model = self._db_editor.frozen_table_model + QApplication.processEvents() + for frozen_column in range(self._db_editor.frozen_table_model.columnCount()): + frozen_index = self._db_editor.frozen_table_model.index(0, frozen_column) + if frozen_index.data() == "alternative": + break + else: + raise RuntimeError("No 'alternative' column found in frozen table") + alternative_header_widget = self._db_editor.ui.frozen_table.indexWidget(frozen_index) + self._db_editor.handle_header_dropped(class_header_widget, alternative_header_widget) + QApplication.processEvents() + expected = [["class1", "alternative"], ["object1", "Base"], ["object2", "Base"]] + self._assert_model_data_equals(frozen_model, expected) + self._select_frozen_row(1) + pivot_model = self._db_editor.pivot_table_model + while pivot_model.rowCount() != 4: + QApplication.processEvents() + expected = [ + ["parameter", "parameter1", "parameter2", None], + ["database", None, None, None], + ["TestPivotHeaderDraggingAndDropping_db", "1.0", "5.0", None], + ["TestPivotHeaderDraggingAndDropping_db", None, None, None], + ] + self._assert_model_data_equals(pivot_model, expected) + self._db_mngr.purge_items({self._db_map: ["entity_class"]}) + self.assertEqual(pivot_model.rowCount(), 0) + + def test_purging_entity_classes_in_index_mode_does_not_crash_pivot_filter_menu(self): + self._add_entity_class_data_with_indexes_values() + self._start() + self._change_pivot_input_type(self._db_editor._INDEX_EXPANSION) + for filter_menu in self._db_editor.filter_menus.values(): + filter_menu._filter._filter_model.canFetchMore(None) + filter_menu._filter._filter_model.fetchMore(None) + while self._db_editor.pivot_table_model.rowCount() != 6: + QApplication.processEvents() + expected = [ + [None, "parameter", "parameter1"], + ["class1", "index", None], + ["object1", "k1", "Map"], + ["object1", "k2", "Map"], + ["object2", "k1", "Map"], + ["object2", "k2", "Map"], + ] + self._assert_model_data_equals(self._db_editor.pivot_table_model, expected) + self._db_mngr.purge_items({self._db_map: ["entity_class"]}) + self.assertEqual(self._db_editor.pivot_table_model.rowCount(), 0) + for filter_menu in self._db_editor.filter_menus.values(): + self.assertEqual(filter_menu._menu_data, {}) + + @staticmethod + def _get_header_widget(table_view, name): + model = table_view.model() + for row, column in itertools.product(range(model.rowCount()), range(model.columnCount())): + source_index = model.index(row, column) + if source_index.data() == name: + break + else: + raise RuntimeError(f"No '{name}' column found in {type(model).__name__}.") + return table_view.indexWidget(source_index) + + def _drag_and_drop_header(self, source_header_widget, target_header_widget): + self._db_editor.handle_header_dropped(source_header_widget, target_header_widget) + QApplication.processEvents() + + def _select_frozen_row(self, row): + model = self._db_editor.frozen_table_model + self.assertGreater(row, 0) + self.assertLess(row, model.rowCount()) + selected = model.index(row, 0) + self._db_editor.ui.frozen_table.selectionModel().setCurrentIndex( + selected, QItemSelectionModel.SelectionFlags.ClearAndSelect + ) + QApplication.processEvents() + self.assertEqual(model._selected_row, row) + + def _assert_model_data_equals(self, model, expected): + row_count = model.rowCount() + column_count = model.columnCount() + self.assertEqual(row_count, len(expected)) + self.assertEqual(column_count, len(expected[0])) + for row, column in itertools.product(range(row_count), range(column_count)): + with self.subTest(row=row, column=column): + self.assertEqual(model.index(row, column).data(), expected[row][column]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/spine_db_editor/widgets/test_url_toolbar.py b/tests/spine_db_editor/widgets/test_url_toolbar.py new file mode 100644 index 000000000..17f98408b --- /dev/null +++ b/tests/spine_db_editor/widgets/test_url_toolbar.py @@ -0,0 +1,50 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for the ``url_toolbar`` module.""" +from unittest import mock +from tempfile import TemporaryDirectory +from PySide6.QtWidgets import QApplication +from spinetoolbox.spine_db_editor.widgets.url_toolbar import UrlToolBar +from tests.spine_db_editor.widgets.spine_db_editor_test_base import DBEditorTestBase +from tests.mock_helpers import create_toolboxui_with_project, clean_up_toolbox, FakeDataStore + + +class TestURLToolbar(DBEditorTestBase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def setUp(self): + super().setUp() + self._temp_dir = TemporaryDirectory() + self._toolbox = create_toolboxui_with_project(self._temp_dir.name) + + def tearDown(self): + super().tearDown() + clean_up_toolbox(self._toolbox) + self._temp_dir.cleanup() + + def test_url_toolbar(self): + self.db_mngr.setParent(self._toolbox) + tb = UrlToolBar(self.spine_db_editor) + tb.add_urls_to_history(self.db_mngr.db_urls) + self.assertEqual({"sqlite://"}, tb.get_previous_urls()) + self.assertEqual({"sqlite://"}, tb.get_next_urls()) + with mock.patch("spinetoolbox.spine_db_editor.widgets.url_toolbar._UrlFilterDialog.show") as mock_show_dialog: + mock_show_dialog.show.return_value = True + tb._show_filter_menu() + mock_show_dialog.assert_called() + # Add fake data stores to project + self._toolbox.project()._project_items = {"a": FakeDataStore("a")} + tb._update_open_project_url_menu() diff --git a/tests/test_ProjectUpgrader.py b/tests/test_ProjectUpgrader.py index be566c6aa..1ce428dd4 100644 --- a/tests/test_ProjectUpgrader.py +++ b/tests/test_ProjectUpgrader.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ProjectUpgrader class. -""" - +"""Unit tests for ProjectUpgrader class.""" import unittest import json from unittest import mock @@ -21,8 +19,7 @@ import os from pathlib import Path from tempfile import TemporaryDirectory -from PySide6.QtWidgets import QApplication - +from PySide6.QtWidgets import QApplication, QMessageBox from spinetoolbox.project_settings import ProjectSettings from spinetoolbox.project_upgrader import ProjectUpgrader from spinetoolbox.resources_icons_rc import qInitResources @@ -42,8 +39,8 @@ def setUpClass(cls): logging.basicConfig( stream=sys.stderr, level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) def setUp(self): @@ -139,12 +136,16 @@ def test_upgrade_v1_to_v2(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 2 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 2 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: # Upgrade to version 2 + mock_mb.return_value = QMessageBox.StandardButton.Yes proj_v2 = pu.upgrade(proj_v1, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(2, proj_v2)) # Check that items were transferred successfully by checking that item names are found in new # 'items' dict and that they contain a dict @@ -166,8 +167,11 @@ def test_upgrade_v2_to_v3(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 3 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 3 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir # Make temp preprocessing_tool.json tool spec file spec_file_path = os.path.join(project_dir, "tool_specs", "preprocessing_tool.json") @@ -177,6 +181,7 @@ def test_upgrade_v2_to_v3(self): proj_v3 = pu.upgrade(proj_v2, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(3, proj_v3)) # Check that items were transferred successfully by checking that item names are found in new # 'items' dict and that they contain a dict @@ -196,8 +201,11 @@ def test_upgrade_v3_to_v4(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 4 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 4 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir # Make temp preprocessing_tool.json tool spec file spec_file_path = os.path.join(project_dir, "tool_specs", "preprocessing_tool.json") @@ -207,6 +215,7 @@ def test_upgrade_v3_to_v4(self): proj_v4 = pu.upgrade(proj_v3, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(4, proj_v4)) # Check that items were transferred successfully by checking that item names are found in new # 'items' dict and that they contain a dict @@ -226,8 +235,11 @@ def test_upgrade_v4_to_v5(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 5 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 5 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir # Make temp preprocessing_tool.json tool spec file spec_file_path = os.path.join(project_dir, "tool_specs", "preprocessing_tool.json") @@ -237,6 +249,7 @@ def test_upgrade_v4_to_v5(self): proj_v5 = pu.upgrade(proj_v4, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(5, proj_v5)) # Check that items were transferred successfully by checking that item names are found in new # 'items' dict and that they contain a dict. Combiners should be gone in v5 @@ -265,8 +278,11 @@ def test_upgrade_v9_to_v10(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 10 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 10 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir # Make temp preprocessing_tool.json tool spec file spec_file_path = os.path.join(project_dir, "tool_specs", "preprocessing_tool.json") @@ -276,6 +292,7 @@ def test_upgrade_v9_to_v10(self): proj_v10 = pu.upgrade(proj_v9, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(10, proj_v10)) v10_items = proj_v10["items"] # Make a list of Gimlet and GdxExporter names in v9 @@ -305,12 +322,16 @@ def test_upgrade_v10_to_v11(self): ) as mock_backup, mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" ) as mock_force_save, mock.patch( - 'spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION', 11 - ): + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 11 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir proj_v11 = pu.upgrade(proj_v10, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(11, proj_v11)) self.assertEqual(proj_v11["project"]["version"], 11) self.assertIn("settings", proj_v11["project"]) @@ -319,6 +340,30 @@ def test_upgrade_v10_to_v11(self): except: self.fail("project settings cannot be deserialized") + def test_upgrade_v11_to_v12(self): + pu = ProjectUpgrader(self.toolbox) + proj_v11 = make_v11_project_dict() + self.assertTrue(pu.is_valid(11, proj_v11)) + with TemporaryDirectory() as project_dir: + with mock.patch( + "spinetoolbox.project_upgrader.ProjectUpgrader.backup_project_file" + ) as mock_backup, mock.patch( + "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" + ) as mock_force_save, mock.patch( + "spinetoolbox.project_upgrader.LATEST_PROJECT_VERSION", 12 + ), mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes + os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir + proj_v12 = pu.upgrade(proj_v11, project_dir) + mock_backup.assert_called_once() + mock_force_save.assert_called_once() + mock_mb.assert_called_once() + self.assertTrue(pu.is_valid(12, proj_v12)) + self.assertEqual(proj_v12["project"]["version"], 12) + self.assertIn("settings", proj_v12["project"]) + def test_upgrade_v1_to_latest(self): pu = ProjectUpgrader(self.toolbox) proj_v1 = make_v1_project_dict() @@ -326,7 +371,12 @@ def test_upgrade_v1_to_latest(self): with TemporaryDirectory() as project_dir: with mock.patch( "spinetoolbox.project_upgrader.ProjectUpgrader.backup_project_file" - ) as mock_backup, mock.patch("spinetoolbox.project_upgrader.ProjectUpgrader.force_save") as mock_force_save: + ) as mock_backup, mock.patch( + "spinetoolbox.project_upgrader.ProjectUpgrader.force_save" + ) as mock_force_save, mock.patch( + "spinetoolbox.project_upgrader.QMessageBox.question" + ) as mock_mb: + mock_mb.return_value = QMessageBox.StandardButton.Yes os.mkdir(os.path.join(project_dir, "tool_specs")) # Make /tool_specs dir # Make temp preprocessing_tool.json tool spec file spec_file_path = os.path.join(project_dir, "tool_specs", "preprocessing_tool.json") @@ -336,6 +386,7 @@ def test_upgrade_v1_to_latest(self): proj_latest = pu.upgrade(proj_v1, project_dir) mock_backup.assert_called_once() mock_force_save.assert_called_once() + mock_mb.assert_called_once() self.assertTrue(pu.is_valid(LATEST_PROJECT_VERSION, proj_latest)) self.assertEqual(proj_latest["project"]["version"], LATEST_PROJECT_VERSION) # Check that items were transferred successfully by checking that item names are found in new @@ -351,7 +402,7 @@ def test_upgrade_v1_to_latest(self): def test_upgrade_with_too_recent_project_version(self): """Tests that projects with too recent versions are not opened.""" - project_dict = make_v10_project_dict() + project_dict = make_v12_project_dict() project_dict["project"]["version"] = LATEST_PROJECT_VERSION + 1 pu = ProjectUpgrader(self.toolbox) self.assertFalse(pu.upgrade(project_dict, project_dir="")) @@ -389,6 +440,12 @@ def make_v11_project_dict(): return _get_project_dict(11) +def make_v12_project_dict(): + v12_proj_dict = make_v11_project_dict() + v12_proj_dict["project"]["version"] = 12 + return v12_proj_dict + + def _get_project_dict(v): """Returns a project dict read from a file according to given version.""" project_json_versions_dir = os.path.join(str(Path(__file__).parent), "test_resources", "project_json_versions") diff --git a/tests/test_SpineDBManager.py b/tests/test_SpineDBManager.py index da4f77441..abaf35b98 100644 --- a/tests/test_SpineDBManager.py +++ b/tests/test_SpineDBManager.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,8 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the spine_db_manager module. -""" +"""Unit tests for the spine_db_manager module.""" +import time from pathlib import Path from tempfile import TemporaryDirectory import unittest @@ -19,7 +19,6 @@ from PySide6.QtCore import Qt, QSettings from PySide6.QtWidgets import QApplication from spinedb_api import ( - DatabaseMapping, to_database, DateTime, Duration, @@ -27,32 +26,44 @@ TimeSeriesFixedResolution, TimeSeriesVariableResolution, ) -from spinedb_api.parameter_value import join_value_and_type, from_database +from spinedb_api.parameter_value import join_value_and_type, from_database, Map, ParameterValueFormatError from spinedb_api import import_functions +from spinedb_api.spine_io.importers.excel_reader import get_mapped_data_from_xlsx +from spinetoolbox.fetch_parent import FlexibleFetchParent + from spinetoolbox.spine_db_manager import SpineDBManager from spinetoolbox.helpers import signal_waiter -def _make_get_item_side_effect(value, type_): - def _get_item(db_map, item_type, id_, only_visible=True): - if item_type != "parameter_value": - return {} - return {"value": value, "type": type_, "list_value_id": None} - - return _get_item - - class TestParameterValueFormatting(unittest.TestCase): """Tests for parameter_value formatting in SpineDBManager.""" + @staticmethod + def _make_get_item_side_effect(value, type_): + def _get_item(db_map, item_type, id_, only_visible=True): + if item_type != "parameter_value": + return {} + try: + parsed_value = from_database(value, type_=type_) + except ParameterValueFormatError as error: + parsed_value = error + return {"parsed_value": parsed_value, "value": value, "type": type_, "list_value_id": None} + + return _get_item + @classmethod def setUpClass(cls): if not QApplication.instance(): QApplication() def setUp(self): - self.db_mngr = SpineDBManager(None, None) - self.db_mngr.get_item = MagicMock() + app_settings = MagicMock() + self.db_mngr = SpineDBManager(app_settings, None, synchronous=True) + logger = MagicMock() + self._db_map = self.db_mngr.get_db_map("sqlite://", logger, create=True) + self._db_map.add_entity_class_item(name="Object") + self._db_map.add_parameter_definition_item(name="x", entity_class_name="Object") + self._db_map.add_entity_item(name="thing", entity_class_name="Object") def tearDown(self): self.db_mngr.close_all_sessions() @@ -60,126 +71,168 @@ def tearDown(self): self.db_mngr.deleteLater() QApplication.processEvents() - def get_value(self, role): - mock_db_map = MagicMock() - id_ = 0 - return self.db_mngr.get_value(mock_db_map, "parameter_value", id_, role) + def _add_value(self, value, alternative="Base"): + db_value, value_type = to_database(value) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name=alternative, + value=db_value, + type=value_type, + ) + self.assertIsNone(error) + return item def test_plain_number_in_display_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "2.3") def test_plain_number_in_edit_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(b"2.3", None)) def test_plain_number_in_tool_tip_role(self): value = 2.3 - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_date_time_in_display_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "2019-07-12T16:00:00") def test_date_time_in_edit_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_date_time_in_tool_tip_role(self): value = DateTime("2019-07-12T16:00") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_duration_in_display_role(self): value = Duration("3Y") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "3Y") def test_duration_in_edit_role(self): value = Duration("2M") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_duration_in_tool_tip_role(self): value = Duration("13D") - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_time_pattern_in_display_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time pattern") def test_time_pattern_in_edit_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_time_pattern_in_tool_tip_role(self): value = TimePattern(["M1-12"], [5.0]) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - self.assertIsNone(self.get_value(Qt.ItemDataRole.ToolTipRole)) + item = self._add_value(value) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertIsNone(formatted) def test_time_series_in_display_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", "7 hours", [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time series") value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Time series") def test_time_series_in_edit_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", "7 hours", [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(*to_database(value))) def test_time_series_in_tool_tip_role(self): + self._db_map.add_alternative_item(name="fixed_resolution") + self._db_map.add_alternative_item(name="variable_resolution") value = TimeSeriesFixedResolution("2019-07-12T08:00", ["7 hours", "12 hours"], [1.1, 2.2, 3.3], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) - self.assertEqual(formatted, "Start: 2019-07-12 08:00:00, resolution: [7h, 12h], length: 3") + item = self._add_value(value, "fixed_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertEqual(formatted, "Start: 2019-07-12 08:00:00
resolution: [7h, 12h]
length: 3
") value = TimeSeriesVariableResolution(["2019-07-12T08:00", "2019-07-12T16:00"], [0.0, 100.0], False, False) - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(*to_database(value)) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) - self.assertEqual(formatted, "Start: 2019-07-12T08:00:00, resolution: variable, length: 2") + item = self._add_value(value, "variable_resolution") + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertEqual(formatted, "Start: 2019-07-12T08:00:00
resolution: variable
length: 2
") def test_broken_value_in_display_role(self): value = b"dubbidubbidu" - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.DisplayRole) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.DisplayRole) self.assertEqual(formatted, "Error") def test_broken_value_in_edit_role(self): value = b"diibadaaba" - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.EditRole) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.EditRole) self.assertEqual(formatted, join_value_and_type(b"diibadaaba", None)) def test_broken_value_in_tool_tip_role(self): value = b"diibadaaba" - self.db_mngr.get_item.side_effect = _make_get_item_side_effect(value, None) - formatted = self.get_value(Qt.ItemDataRole.ToolTipRole) - self.assertTrue(formatted.startswith('Could not decode the value')) + item, error = self._db_map.add_parameter_value_item( + entity_class_name="Object", + entity_byname=("thing",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=None, + ) + self.assertIsNone(error) + formatted = self.db_mngr.get_value(self._db_map, item, Qt.ItemDataRole.ToolTipRole) + self.assertTrue(formatted.startswith("Could not decode the value")) class TestAddItems(unittest.TestCase): @@ -211,31 +264,28 @@ def tearDown(self): def test_add_metadata(self): db_map = self._db_mngr.get_db_map(self._db_url, self._logger, create=True) - - def callback(db_map_data): - self.assertEqual( - db_map_data, {db_map: [{"id": 1, "name": "my_metadata", "value": "Metadata value.", "commit_id": 2}]} - ) - - db_map_data = {db_map: [{"name": "my_metadata", "value": "Metadata value."}]} - self._db_mngr.add_items(db_map_data, "metadata", callback=callback) + db_map_data = {db_map: [{"name": "my_metadata", "value": "Metadata value.", "id": 1}]} + self._db_mngr.add_items("metadata", db_map_data) + self.assertEqual( + self._db_mngr.get_item(db_map, "metadata", 1).resolve(), + {"name": "my_metadata", "value": "Metadata value.", "id": 1}, + ) def test_add_object_metadata(self): - db_map = DatabaseMapping(self._db_url, create=True) + db_map = self._db_mngr.get_db_map(self._db_url, None, create=True) import_functions.import_object_classes(db_map, ("my_class",)) import_functions.import_objects(db_map, (("my_class", "my_object"),)) import_functions.import_metadata(db_map, ('{"metaname": "metavalue"}',)) db_map.commit_session("Add test data.") - db_map.connection.close() - - def callback(db_map_data): - self.assertEqual(db_map_data, {db_map: [{'entity_id': 1, 'metadata_id': 1, 'commit_id': None, 'id': 1}]}) - - db_map_data = {db_map: [{"entity_id": 1, "metadata_id": 1}]} - self._db_mngr.add_items(db_map_data, "entity_metadata", callback=callback) + db_map.close() + db_map_data = {db_map: [{"entity_id": 1, "metadata_id": 1, "id": 1}]} + self._db_mngr.add_items("entity_metadata", db_map_data) + self.assertEqual( + self._db_mngr.get_item(db_map, "entity_metadata", 1).resolve(), {"entity_id": 1, "metadata_id": 1, "id": 1} + ) -class TestImportData(unittest.TestCase): +class TestImportExportData(unittest.TestCase): @classmethod def setUpClass(cls): if not QApplication.instance(): @@ -246,17 +296,74 @@ def setUp(self): mock_settings.value.side_effect = lambda *args, **kwargs: 0 self._db_mngr = SpineDBManager(mock_settings, None) logger = MagicMock() + self.editor = MagicMock() self._temp_dir = TemporaryDirectory() url = "sqlite:///" + self._temp_dir.name + "/db.sqlite" self._db_map = self._db_mngr.get_db_map(url, logger, codename="database", create=True) def tearDown(self): self._db_mngr.close_all_sessions() - while not self._db_map.connection.closed: + while not self._db_map.closed: QApplication.processEvents() self._db_mngr.clean_up() self._temp_dir.cleanup() + def test_export_then_import_time_series_parameter_value(self): + file_path = str(Path(self._temp_dir.name) / "test.xlsx") + data = { + "entity_classes": [("A", (), None, None, False)], + "entities": [("A", "aa", None)], + "parameter_definitions": [("A", "test1", None, None, None)], + "parameter_values": [ + ( + "A", + "aa", + "test1", + { + "type": "time_series", + "index": { + "start": "2000-01-01 00:00:00", + "resolution": "1h", + "ignore_year": False, + "repeat": False, + }, + "data": [0.0, 1.0, 2.0, 4.0, 8.0, 0.0], + }, + "Base", + ) + ], + "alternatives": [("Base", "Base alternative")], + } + self._db_mngr.export_to_excel(file_path, data, self.editor) + mapped_data, errors = get_mapped_data_from_xlsx(file_path) + self.assertEqual(errors, []) + self._db_mngr.import_data({self._db_map: mapped_data}) + self._db_map.commit_session("imported items") + value = self._db_map.query(self._db_map.entity_parameter_value_sq).one() + time_series = from_database(value.value, value.type) + expected_result = TimeSeriesVariableResolution( + ( + "2000-01-01T00:00:00", + "2000-01-01T01:00:00", + "2000-01-01T02:00:00", + "2000-01-01T03:00:00", + "2000-01-01T04:00:00", + "2000-01-01T05:00:00", + ), + (0.0, 1.0, 2.0, 4.0, 8.0, 0.0), + False, + False, + ) + self.assertEqual(time_series, expected_result) + + def test_export_empty_data_does_not_traceback_because_there_is_nothing_to_commit(self): + file_path = str(Path(self._temp_dir.name) / "test.xlsx") + data = {} + self._db_mngr.export_to_excel(file_path, data, self.editor) + mapped_data, errors = get_mapped_data_from_xlsx(file_path) + self.assertEqual(errors, []) + self.assertEqual(mapped_data, {"alternatives": ["Base"]}) + def test_import_parameter_value_lists(self): with signal_waiter( self._db_mngr.items_added, condition=lambda item_type, _: item_type == "list_value" @@ -269,12 +376,11 @@ def test_import_parameter_value_lists(self): list_values = self._db_mngr.get_items(self._db_map, "list_value") self.assertEqual(len(value_lists), 1) value_list = value_lists[0] - index_to_id = dict(zip(value_list["value_id_list"], value_list["value_index_list"])) - values = len(index_to_id) * [None] - for row in list_values: - value = from_database(row["value"], row["type"]) - values[index_to_id[row["id"]]] = value - self.assertEqual(values, ["first value", "second value"]) + self.assertEqual(value_list["name"], "list_1") + self.assertEqual( + [(from_database(x["value"], x["type"]), x["index"]) for x in list_values], + [("first value", 0), ("second value", 1)], + ) class TestOpenDBEditor(unittest.TestCase): @@ -293,16 +399,21 @@ def setUp(self): def test_open_db_editor(self): editors = list(self._db_mngr.get_all_multi_spine_db_editors()) self.assertFalse(editors) - self._db_mngr.open_db_editor({self._db_url: "test"}) + self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) editors = list(self._db_mngr.get_all_multi_spine_db_editors()) self.assertEqual(len(editors), 1) - self._db_mngr.open_db_editor({self._db_url: "test"}) + self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) editors = list(self._db_mngr.get_all_multi_spine_db_editors()) self.assertEqual(len(editors), 1) - self._db_mngr.open_db_editor({self._db_url: "not_the_same"}) + self._db_mngr.open_db_editor({self._db_url: "not_the_same"}, reuse_existing_editor=True) self.assertEqual(len(editors), 1) editor = editors[0] - self.assertEqual(editor.tab_widget.count(), 2) + self.assertEqual(editor.tab_widget.count(), 1) + # Finally try to open the first tab again + self._db_mngr.open_db_editor({self._db_url: "test"}, reuse_existing_editor=True) + editors = list(self._db_mngr.get_all_multi_spine_db_editors()) + editor = editors[0] + self.assertEqual(editor.tab_widget.count(), 1) for editor in self._db_mngr.get_all_multi_spine_db_editors(): QApplication.processEvents() editor.close() @@ -322,5 +433,128 @@ def tearDown(self): running = False -if __name__ == '__main__': +class TestDuplicateEntity(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db_codename = cls.__name__ + "_db" + if not QApplication.instance(): + QApplication() + + def setUp(self): + self._db_mngr = SpineDBManager(QSettings(), None) + logger = MagicMock() + self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename=self.db_codename, create=True) + + def tearDown(self): + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + + def _assert_success(self, result): + item, error = result + self.assertIsNone(error) + return item + + def test_duplicates_parameter_values_and_entity_alternatives(self): + self._assert_success(self._db_map.add_alternative_item(name="low highs")) + self._assert_success(self._db_map.add_entity_class_item(name="Widget")) + self._assert_success(self._db_map.add_parameter_definition_item(name="x", entity_class_name="Widget")) + self._assert_success(self._db_map.add_entity_item(name="capital W", entity_class_name="Widget")) + self._assert_success( + self._db_map.add_entity_alternative_item( + entity_class_name="Widget", entity_byname=("capital W",), alternative_name="Base", active=False + ) + ) + self._assert_success( + self._db_map.add_entity_alternative_item( + entity_class_name="Widget", entity_byname=("capital W",), alternative_name="low highs", active=True + ) + ) + value, value_type = to_database(2.3) + self._assert_success( + self._db_map.add_parameter_value_item( + entity_class_name="Widget", + parameter_definition_name="x", + entity_byname=("capital W",), + alternative_name="low highs", + type=value_type, + value=value, + ) + ) + self._db_mngr.duplicate_entity("capital W", "lower case w", "Widget", [self._db_map]) + entities = self._db_map.get_entity_items() + self.assertEqual(len(entities), 2) + self.assertEqual({e["name"] for e in entities}, {"capital W", "lower case w"}) + self.assertEqual({e["entity_class_name"] for e in entities}, {"Widget"}) + entity_alternatives = self._db_map.get_entity_alternative_items() + self.assertEqual(len(entity_alternatives), 4) + self.assertEqual( + {(ea["entity_byname"], ea["alternative_name"], ea["active"]) for ea in entity_alternatives}, + { + (("capital W",), "Base", False), + (("capital W",), "low highs", True), + (("lower case w",), "Base", False), + (("lower case w",), "low highs", True), + }, + ) + values = self._db_map.get_parameter_value_items() + self.assertEqual(len(values), 2) + self.assertEqual({v["entity_class_name"] for v in values}, {"Widget"}) + self.assertEqual({v["parameter_definition_name"] for v in values}, {"x"}) + self.assertEqual({v["entity_byname"] for v in values}, {("capital W",), ("lower case w",)}) + self.assertEqual({v["alternative_name"] for v in values}, {"low highs"}) + + +class TestUpdateExpandedParameterValues(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def setUp(self): + mock_settings = MagicMock() + mock_settings.value.side_effect = lambda *args, **kwargs: 0 + self._db_mngr = SpineDBManager(mock_settings, None) + self._logger = MagicMock() + self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="database", create=True) + + def tearDown(self): + self._db_mngr.close_all_sessions() + while not self._db_map.closed: + QApplication.processEvents() + self._db_mngr.clean_up() + + def test_updating_indexed_value_changes_the_unparsed_value_in_database(self): + self._db_map.add_entity_class_item(name="Gadget") + self._db_map.add_parameter_definition_item(name="x", entity_class_name="Gadget") + self._db_map.add_entity_item(name="biometer", entity_class_name="Gadget") + value, value_type = to_database(Map(["a"], ["b"])) + value_item, error = self._db_map.add_parameter_value_item( + entity_class_name="Gadget", + entity_byname=("biometer",), + parameter_definition_name="x", + alternative_name="Base", + value=value, + type=value_type, + ) + self.assertIsNone(error) + items_updated = MagicMock() + fetch_parent = FlexibleFetchParent("parameter_value", handle_items_updated=items_updated) + self._db_mngr.register_fetch_parent(self._db_map, fetch_parent) + self._db_mngr.fetch_more(self._db_map, fetch_parent) + new_value, new_type = to_database("c") + update_item = {"id": value_item["id"], "index": "a", "value": new_value, "type": new_type} + self._db_mngr.update_expanded_parameter_values({self._db_map: [update_item]}) + wait_start = time.monotonic() + while not items_updated.called: + QApplication.processEvents() + if time.monotonic() - wait_start > 2.0: + self.fail("timeout while waiting for update signal") + updated_item = self._db_map.get_parameter_value_item(id=value_item["id"]) + update_value = from_database(updated_item["value"], updated_item["type"]) + self.assertEqual(update_value, Map(["a"], ["c"])) + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_SpineToolboxProject.py b/tests/test_SpineToolboxProject.py index 28391f6a3..3347bf4ea 100644 --- a/tests/test_SpineToolboxProject.py +++ b/tests/test_SpineToolboxProject.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for SpineToolboxProject class. -""" +"""Unit tests for the SpineToolboxProject class.""" import json import os.path from pathlib import Path @@ -19,7 +18,8 @@ import unittest from unittest import mock from PySide6.QtCore import QVariantAnimation -from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QApplication, QMessageBox import networkx as nx from spine_engine.project_item.project_item_specification import ProjectItemSpecification from spine_engine.spine_engine import ItemExecutionFinishState @@ -28,8 +28,8 @@ from spinetoolbox.helpers import SignalWaiter from spinetoolbox.project_item.project_item import ProjectItem from spinetoolbox.project_item.project_item_factory import ProjectItemFactory -from spinetoolbox.project_item.logging_connection import LoggingConnection -from spinetoolbox.config import PROJECT_LOCAL_DATA_DIR_NAME, PROJECT_LOCAL_DATA_FILENAME +from spinetoolbox.project_item.logging_connection import LoggingConnection, LoggingJump +from spinetoolbox.config import LATEST_PROJECT_VERSION, PROJECT_LOCAL_DATA_DIR_NAME, PROJECT_LOCAL_DATA_FILENAME from spinetoolbox.project import node_successors from tests.mock_helpers import ( clean_up_toolbox, @@ -81,12 +81,11 @@ def node_is_isolated(project, node): def test_add_data_store(self): name = "DS" add_ds(self.toolbox.project(), self.toolbox.item_factories, name) - # Check that an item with the created name is found from project item model - found_index = self.toolbox.project_item_model.find_item(name) - found_item = self.toolbox.project_item_model.item(found_index).project_item - self.assertEqual(found_item.name, name) + # Check that an item with the created name is in project + item = self.toolbox.project().get_item(name) + self.assertEqual(item.name, name) # Check that the created item is a Data Store - self.assertEqual(found_item.item_type(), "Data Store") + self.assertEqual(item.item_type(), "Data Store") # Check that dag handler has this and only this node self.check_dag_handler(name) @@ -104,36 +103,39 @@ def check_dag_handler(self, name): def test_add_data_connection(self): name = "DC" add_dc(self.toolbox.project(), self.toolbox.item_factories, name) - # Check that an item with the created name is found from project item model - found_index = self.toolbox.project_item_model.find_item(name) - found_item = self.toolbox.project_item_model.item(found_index).project_item - self.assertEqual(found_item.name, name) + # Check that an item with the created name is in project + item = self.toolbox.project().get_item(name) + self.assertEqual(item.name, name) # Check that the created item is a Data Connection - self.assertEqual(found_item.item_type(), "Data Connection") + self.assertEqual(item.item_type(), "Data Connection") # Check that dag handler has this and only this node self.check_dag_handler(name) + # test get_items_by_type() + data_connections = self.toolbox.project().get_items_by_type("Data Connection") + self.assertEqual(1, len(data_connections)) + self.assertIsInstance(data_connections[0], ProjectItem) + tools = self.toolbox.project().get_items_by_type("Tool") + self.assertEqual(0, len(tools)) def test_add_tool(self): name = "Tool" add_tool(self.toolbox.project(), self.toolbox.item_factories, name) - # Check that an item with the created name is found from project item model - found_index = self.toolbox.project_item_model.find_item(name) - found_item = self.toolbox.project_item_model.item(found_index).project_item - self.assertEqual(found_item.name, name) + # Check that an item with the created name is in project + item = self.toolbox.project().get_item(name) + self.assertEqual(item.name, name) # Check that the created item is a Tool - self.assertEqual(found_item.item_type(), "Tool") + self.assertEqual(item.item_type(), "Tool") # Check that dag handler has this and only this node self.check_dag_handler(name) def test_add_view(self): name = "View" add_view(self.toolbox.project(), self.toolbox.item_factories, name) - # Check that an item with the created name is found from project item model - found_index = self.toolbox.project_item_model.find_item(name) - found_item = self.toolbox.project_item_model.item(found_index).project_item - self.assertEqual(found_item.name, name) + # Check that an item with the created name is in project + item = self.toolbox.project().get_item(name) + self.assertEqual(item.name, name) # Check that the created item is a View - self.assertEqual(found_item.item_type(), "View") + self.assertEqual(item.item_type(), "View") # Check that dag handler has this and only this node self.check_dag_handler(name) @@ -172,6 +174,9 @@ def test_add_all_available_items(self): self.assertEqual(exporter_name, exporter.name) merger = p.get_item(merger_name) self.assertEqual(merger_name, merger.name) + # Test has_items(), and get_items() + self.assertTrue(p.has_items()) + self.assertEqual(8, len(p.get_items())) # DAG handler should now have eight graphs, each with one item dags = [dag for dag in self.toolbox.project()._dag_iterator()] self.assertEqual(8, len(dags)) @@ -196,10 +201,10 @@ def test_add_all_available_items(self): def test_remove_item_by_name(self): view_name = "View" add_view(self.toolbox.project(), self.toolbox.item_factories, view_name) - view = self.toolbox.project_item_model.get_item(view_name) + view = self.toolbox.project().get_item(view_name) self.assertEqual(view_name, view.name) self.toolbox.project().remove_item_by_name(view_name) - self.assertEqual(self.toolbox.project_item_model.n_items(), 0) + self.assertEqual(self.toolbox.project().n_items, 0) def test_remove_item_by_name_removes_outgoing_connections(self): project = self.toolbox.project() @@ -208,16 +213,16 @@ def test_remove_item_by_name_removes_outgoing_connections(self): view2_name = "View 2" add_view(project, self.toolbox.item_factories, view2_name) project.add_connection(LoggingConnection(view1_name, "top", view2_name, "bottom", toolbox=self.toolbox)) - view = self.toolbox.project_item_model.get_item(view1_name) + view = self.toolbox.project().get_item(view1_name) self.assertEqual(view1_name, view.name) - view = self.toolbox.project_item_model.get_item(view2_name) + view = self.toolbox.project().get_item(view2_name) self.assertEqual(view2_name, view.name) - self.assertEqual(self.toolbox.project_item_model.n_items(), 2) + self.assertEqual(self.toolbox.project().n_items, 2) self.assertEqual(len(project.connections), 1) project.remove_item_by_name(view1_name) - self.assertEqual(self.toolbox.project_item_model.n_items(), 1) + self.assertEqual(self.toolbox.project().n_items, 1) self.assertEqual(len(project.connections), 0) - view = self.toolbox.project_item_model.get_item(view2_name) + view = self.toolbox.project().get_item(view2_name) self.assertEqual(view2_name, view.name) self.assertTrue(self.node_is_isolated(project, view2_name)) @@ -228,16 +233,16 @@ def test_remove_item_by_name_removes_incoming_connections(self): view2_name = "View 2" add_view(project, self.toolbox.item_factories, view2_name) project.add_connection(LoggingConnection(view1_name, "top", view2_name, "bottom", toolbox=self.toolbox)) - view = self.toolbox.project_item_model.get_item(view1_name) + view = self.toolbox.project().get_item(view1_name) self.assertEqual(view1_name, view.name) - view = self.toolbox.project_item_model.get_item(view2_name) + view = self.toolbox.project().get_item(view2_name) self.assertEqual(view2_name, view.name) - self.assertEqual(self.toolbox.project_item_model.n_items(), 2) + self.assertEqual(self.toolbox.project().n_items, 2) self.assertEqual(len(project.connections), 1) project.remove_item_by_name(view2_name) - self.assertEqual(self.toolbox.project_item_model.n_items(), 1) + self.assertEqual(self.toolbox.project().n_items, 1) self.assertEqual(len(project.connections), 0) - view = self.toolbox.project_item_model.get_item(view1_name) + view = self.toolbox.project().get_item(view1_name) self.assertEqual(view1_name, view.name) self.assertTrue(self.node_is_isolated(project, view1_name)) @@ -364,17 +369,42 @@ def test_execute_selected_items_within_single_dag(self): self.assertTrue(dc3_executable.execute_called) self.assertTrue(dc5_executable.execute_called) - def test_executing_cyclic_dag_fails_graciously(self): + def test_making_a_yellow_feedback_loop_makes_a_jump_instead(self): item1 = add_dc(self.toolbox.project(), self.toolbox.item_factories, "DC") item2 = add_view(self.toolbox.project(), self.toolbox.item_factories, "View") self.toolbox.project().add_connection( LoggingConnection(item1.name, "right", item2.name, "left", toolbox=self.toolbox) ) - self.toolbox.project().add_connection( - LoggingConnection(item2.name, "bottom", item1.name, "top", toolbox=self.toolbox) - ) - self.toolbox.project().execute_project() - self.assertFalse(self.toolbox.project()._execution_in_progress) + # There should be one connection and no jumps in the project + self.assertEqual(1, len(self.toolbox.project().connections)) + self.assertEqual("DC", self.toolbox.project().connections[0].source) + self.assertEqual("View", self.toolbox.project().connections[0].destination) + self.assertEqual(0, len(self.toolbox.project()._jumps)) + with mock.patch("PySide6.QtWidgets.QMessageBox.exec") as mock_msgbox_exec: + mock_msgbox_exec.return_value = QMessageBox.StandardButton.Cancel + self.toolbox.project().add_connection( + LoggingConnection(item2.name, "bottom", item1.name, "top", toolbox=self.toolbox) + ) + # Operation was cancelled + self.assertEqual(1, len(self.toolbox.project().connections)) + self.assertEqual(0, len(self.toolbox.project()._jumps)) + # Do again but click Ok (Add Loop) + mock_msgbox_exec.assert_called() + self.assertEqual(1, mock_msgbox_exec.call_count) + mock_msgbox_exec.return_value = QMessageBox.StandardButton.Ok + self.toolbox.project().add_connection( + LoggingConnection(item2.name, "bottom", item1.name, "top", toolbox=self.toolbox) + ) + self.assertEqual(2, mock_msgbox_exec.call_count) + # There should be one connection and one jump in the project + self.assertEqual(1, len(self.toolbox.project().connections)) + self.assertIsInstance(self.toolbox.project().connections[0], LoggingConnection) + self.assertEqual("DC", self.toolbox.project().connections[0].source) + self.assertEqual("View", self.toolbox.project().connections[0].destination) + self.assertEqual(1, len(self.toolbox.project()._jumps)) + self.assertIsInstance(self.toolbox.project()._jumps[0], LoggingJump) + self.assertEqual("View", self.toolbox.project()._jumps[0].source) + self.assertEqual("DC", self.toolbox.project()._jumps[0].destination) def test_rename_project(self): new_name = "New Project Name" @@ -549,13 +579,13 @@ def test_save_when_storing_item_local_data(self): "jumps": [], "settings": {"enable_execute_all": True}, "specifications": {}, - "version": 11, + "version": LATEST_PROJECT_VERSION, }, }, ) with Path(project.config_dir, PROJECT_LOCAL_DATA_DIR_NAME, PROJECT_LOCAL_DATA_FILENAME).open() as fp: local_data_dict = json.load(fp) - self.assertEqual(local_data_dict, {'items': {'test item': {'a': {'b': 1, 'd': 3}}}}) + self.assertEqual(local_data_dict, {"items": {"test item": {"a": {"b": 1, "d": 3}}}}) def test_load_when_storing_item_local_data(self): project = self.toolbox.project() @@ -576,10 +606,16 @@ def test_load_when_storing_item_local_data(self): def test_add_and_save_specification(self): project = self.toolbox.project() - specification = _MockSpecification( - "a specification", "Specification for testing.", "Tester", "Testing category" - ) - project.add_specification(specification) + self.toolbox.item_factories = {"Tester": ProjectItemFactory()} + specification = _MockSpecification("a specification", "Specification for testing.", "Tester") + with mock.patch.object(ProjectItemFactory, "icon") as mock_icon, mock.patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + project.add_specification(specification) + mock_icon.assert_called() + mock_icon_color.assert_called() self.assertTrue(specification.is_equivalent(project.get_specification("a specification"))) specification_dir = Path(self._temp_dir.name) / ".spinetoolbox" / "specifications" / "Tester" self.assertTrue(specification_dir.exists()) @@ -594,10 +630,18 @@ def test_add_and_save_specification(self): def test_add_and_save_specification_with_local_data(self): project = self.toolbox.project() + self.toolbox.item_factories = {"Tester": _MockItemFactoryForLocalDataTests} specification = _MockSpecificationWithLocalData( - "a specification", "Specification for testing.", "Tester", "Testing category", "my precious data" + "a specification", "Specification for testing.", "Tester", "my precious data" ) - project.add_specification(specification) + with mock.patch.object(ProjectItemFactory, "icon") as mock_icon, mock.patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + project.add_specification(specification) + mock_icon.assert_called() + mock_icon_color.assert_called() self.assertTrue(specification.is_equivalent(project.get_specification("a specification"))) specification_dir = Path(self._temp_dir.name) / ".spinetoolbox" / "specifications" / "Tester" self.assertTrue(specification_dir.exists()) @@ -619,16 +663,24 @@ def test_add_and_save_specification_with_local_data(self): def test_renaming_specification_with_local_data_updates_local_data_file(self): project = self.toolbox.project() + self.toolbox.item_factories = {"Tester": _MockItemFactoryForLocalDataTests} original_specification = _MockSpecificationWithLocalData( - "a specification", "Specification for testing.", "Tester", "Testing category", "my precious data" - ) - project.add_specification(original_specification) - local_data_file = Path(self._temp_dir.name) / ".spinetoolbox" / "local" / "specification_local_data.json" - self.assertTrue(local_data_file.exists()) - specification = _MockSpecificationWithLocalData( - "another specification", "Specification for testing.", "Tester", "Testing category", "my precious data" + "a specification", "Specification for testing.", "Tester", "my precious data" ) - project.replace_specification("a specification", specification) + with mock.patch.object(ProjectItemFactory, "icon") as mock_icon, mock.patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + project.add_specification(original_specification) + local_data_file = Path(self._temp_dir.name) / ".spinetoolbox" / "local" / "specification_local_data.json" + self.assertTrue(local_data_file.exists()) + specification = _MockSpecificationWithLocalData( + "another specification", "Specification for testing.", "Tester", "my precious data" + ) + project.replace_specification("a specification", specification) + mock_icon.assert_called() + mock_icon_color.assert_called() specification_dir = Path(self._temp_dir.name) / ".spinetoolbox" / "specifications" / "Tester" self.assertTrue(specification_dir.exists()) specification_file = specification_dir / (specification.short_name + ".json") @@ -646,16 +698,22 @@ def test_renaming_specification_with_local_data_updates_local_data_file(self): def test_replace_specification_with_local_data_by_one_without_removes_local_data_from_the_file(self): project = self.toolbox.project() + self.toolbox.item_factories = {"Tester": _MockItemFactoryForLocalDataTests} specification_with_local_data = _MockSpecificationWithLocalData( - "a specification", "Specification for testing.", "Tester", "Testing category", "my precious data" - ) - project.add_specification(specification_with_local_data) - local_data_file = Path(self._temp_dir.name) / ".spinetoolbox" / "local" / "specification_local_data.json" - self.assertTrue(local_data_file.exists()) - specification = _MockSpecification( - "another specification", "Specification without local data", "Tester", "Testing category" + "a specification", "Specification for testing.", "Tester", "my precious data" ) - project.replace_specification("a specification", specification) + with mock.patch.object(ProjectItemFactory, "icon") as mock_icon, mock.patch.object( + ProjectItemFactory, "icon_color" + ) as mock_icon_color: + mock_icon.return_value = ":/icons/item_icons/hammer.svg" + mock_icon_color.return_value = QColor("white") + project.add_specification(specification_with_local_data) + local_data_file = Path(self._temp_dir.name) / ".spinetoolbox" / "local" / "specification_local_data.json" + self.assertTrue(local_data_file.exists()) + specification = _MockSpecification("another specification", "Specification without local data", "Tester") + project.replace_specification("a specification", specification) + mock_icon.assert_called() + mock_icon_color.assert_called() specification_dir = Path(self._temp_dir.name) / ".spinetoolbox" / "specifications" / "Tester" self.assertTrue(specification_dir.exists()) specification_file = specification_dir / (specification.short_name + ".json") @@ -670,7 +728,7 @@ def test_replace_specification_with_local_data_by_one_without_removes_local_data def _make_mock_executable(self, item): item_name = item.name - item = self.toolbox.project_item_model.get_item(item_name).project_item + item = self.toolbox.project().get_item(item_name) item_executable = _MockExecutableItem(item_name, self.toolbox.project().project_dir, self.toolbox) animation = QVariantAnimation() animation.setDuration(0) @@ -719,10 +777,6 @@ def item_dict_local_entries(): def item_type(): return "Tester" - @staticmethod - def item_category(): - return "Tools" - def set_rank(self, rank): pass @@ -747,8 +801,8 @@ def is_equivalent(self, other): class _MockSpecificationWithLocalData(ProjectItemSpecification): - def __init__(self, name, description, item_type, item_category, local_data): - super().__init__(name, description, item_type, item_category) + def __init__(self, name, description, item_type, local_data): + super().__init__(name, description, item_type) self._local_data = local_data def to_dict(self): @@ -769,5 +823,5 @@ def _definition_local_entries(): return [("data",)] -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_ToolboxUI.py b/tests/test_ToolboxUI.py index 8ac84ff21..390fe3e50 100644 --- a/tests/test_ToolboxUI.py +++ b/tests/test_ToolboxUI.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ToolboxUI class. -""" - +"""Unit tests for ToolboxUI class.""" from pathlib import Path from contextlib import contextmanager from tempfile import TemporaryDirectory @@ -22,23 +20,22 @@ import os import sys import spinetoolbox.ui_main -from PySide6.QtWidgets import QApplication, QMessageBox -from PySide6.QtCore import QSettings, Qt, QPoint, QItemSelectionModel, QPointF, QMimeData +from PySide6.QtWidgets import QApplication, QMessageBox, QMenu +from PySide6.QtCore import QSettings, Qt, QPoint, QPointF, QMimeData from PySide6.QtTest import QTest from PySide6.QtGui import QDropEvent -from spinetoolbox.project_item_icon import ProjectItemIcon from spinetoolbox.project import SpineToolboxProject -from spinetoolbox.widgets.project_item_drag import ProjectItemDragMixin +from spinetoolbox.project_item.project_item import ProjectItem +from spinetoolbox.widgets.project_item_drag import ProjectItemDragMixin, NiceButton from spinetoolbox.widgets.persistent_console_widget import PersistentConsoleWidget from spinetoolbox.link import Link -from spinetoolbox.mvcmodels.project_tree_item import RootProjectTreeItem from spinetoolbox.resources_icons_rc import qInitResources from .mock_helpers import ( clean_up_toolbox, create_toolboxui, create_project, - add_ds, add_dc, + add_dc_trough_undo_stack, add_tool, qsettings_value_side_effect, ) @@ -57,8 +54,8 @@ def setUpClass(cls): logging.basicConfig( stream=sys.stderr, level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) def setUp(self): @@ -75,70 +72,6 @@ def tearDown(self): if self._temp_dir is not None: self._temp_dir.cleanup() - def test_init_project_item_model_without_project(self): - """Test that a new project item model contains 6 category items. - Note: This test is done WITHOUT a project open. - """ - self.assertIsNone(self.toolbox.project()) # Make sure that there is no project open - self.toolbox.init_project_item_model() - self.check_init_project_item_model() - - def test_init_project_item_model_with_project(self): - """Test that project item model is initialized successfully. - Note: This test is done WITH a project. - Mock save_project() and create_dir() so that .proj file and project directory (and work directory) are - not actually created. - """ - with TemporaryDirectory() as project_dir: - create_project(self.toolbox, project_dir) - self.assertIsInstance(self.toolbox.project(), SpineToolboxProject) # Check that a project is open - self.toolbox.init_project_item_model() - self.check_init_project_item_model() - - def check_init_project_item_model(self): - """Checks that category items are created as expected.""" - n = self.toolbox.project_item_model.rowCount() - # Data Stores, Data Connections, Tools, Views, Importers, Exporters, Manipulators - self.assertEqual(n, 7) - # Check that there's only one column - self.assertEqual(self.toolbox.project_item_model.columnCount(), 1) - # Check that the items DisplayRoles are (In this particular order) - item1 = self.toolbox.project_item_model.root().child(0) - self.assertEqual(item1.name, "Data Stores", "Item on row 0 is not 'Data Stores'") - self.assertIsInstance( - item1.parent(), RootProjectTreeItem, "Parent item of category item on row 0 should be root" - ) - item2 = self.toolbox.project_item_model.root().child(1) - self.assertEqual(item2.name, "Data Connections", "Item on row 1 is not 'Data Connections'") - self.assertIsInstance( - item2.parent(), RootProjectTreeItem, "Parent item of category item on row 1 should be root" - ) - item3 = self.toolbox.project_item_model.root().child(2) - self.assertEqual(item3.name, "Tools", "Item on row 2 is not 'Tools'") - self.assertIsInstance( - item3.parent(), RootProjectTreeItem, "Parent item of category item on row 2 should be root" - ) - item4 = self.toolbox.project_item_model.root().child(3) - self.assertEqual(item4.name, "Views", "Item on row 3 is not 'Views'") - self.assertIsInstance( - item4.parent(), RootProjectTreeItem, "Parent item of category item on row 3 should be root" - ) - item5 = self.toolbox.project_item_model.root().child(4) - self.assertEqual(item5.name, "Importers", "Item on row 4 is not 'Importers'") - self.assertIsInstance( - item5.parent(), RootProjectTreeItem, "Parent item of category item on row 4 should be root" - ) - item6 = self.toolbox.project_item_model.root().child(5) - self.assertEqual(item6.name, "Exporters", "Item on row 5 is not 'Exporters'") - self.assertIsInstance( - item6.parent(), RootProjectTreeItem, "Parent item of category item on row 5 should be root" - ) - item7 = self.toolbox.project_item_model.root().child(6) - self.assertEqual(item7.name, "Manipulators", "Item on row 6 is not 'Manipulators'") - self.assertIsInstance( - item7.parent(), RootProjectTreeItem, "Parent item of category item on row 6 should be root" - ) - def test_init_specification_model(self): """Check that specification model has no items after init and that signals are connected just once. @@ -173,25 +106,21 @@ def test_open_project(self): self.toolbox.open_project(project_dir) self.assertIsInstance(self.toolbox.project(), SpineToolboxProject) # Check that project contains four items - self.assertEqual(self.toolbox.project_item_model.n_items(), 4) + self.assertEqual(self.toolbox.project().n_items, 4) # Check that design view has three links links = [item for item in self.toolbox.ui.graphicsView.scene().items() if isinstance(item, Link)] self.assertEqual(len(links), 3) # Check project items have the right links - index_a = self.toolbox.project_item_model.find_item("a") - item_a = self.toolbox.project_item_model.item(index_a).project_item + item_a = self.toolbox.project().get_item("a") icon_a = item_a.get_icon() links_a = [link for conn in icon_a.connectors.values() for link in conn.links] - index_b = self.toolbox.project_item_model.find_item("b") - item_b = self.toolbox.project_item_model.item(index_b).project_item + item_b = self.toolbox.project().get_item("b") icon_b = item_b.get_icon() links_b = [link for conn in icon_b.connectors.values() for link in conn.links] - index_c = self.toolbox.project_item_model.find_item("c") - item_c = self.toolbox.project_item_model.item(index_c).project_item + item_c = self.toolbox.project().get_item("c") icon_c = item_c.get_icon() links_c = [link for conn in icon_c.connectors.values() for link in conn.links] - index_d = self.toolbox.project_item_model.find_item("d") - item_d = self.toolbox.project_item_model.item(index_d).project_item + item_d = self.toolbox.project().get_item("d") icon_d = item_d.get_icon() links_d = [link for conn in icon_d.connectors.values() for link in conn.links] self.assertEqual(len(links_a), 1) @@ -253,7 +182,8 @@ def test_save_project(self): ), mock.patch("PySide6.QtWidgets.QFileDialog.getExistingDirectory") as mock_dir_getter: mock_dir_getter.return_value = self._temp_dir.name self.toolbox.new_project() - add_dc(self.toolbox.project(), self.toolbox.item_factories, "DC") + add_dc_trough_undo_stack(self.toolbox, "DC") + self.assertFalse(self.toolbox.undo_stack.isClean()) self.toolbox.save_project() self.assertTrue(self.toolbox.undo_stack.isClean()) with mock.patch("spinetoolbox.ui_main.QSettings.value") as mock_qsettings_value: @@ -270,6 +200,44 @@ def test_save_project(self): self.assertIsNotNone(self.toolbox.project()) self.assertEqual(self.toolbox.project().get_item("DC").name, "DC") + def test_prevent_project_closing_with_unsaved_changes(self): + self._temp_dir = TemporaryDirectory() + with mock.patch("spinetoolbox.ui_main.QSettings.setValue"), mock.patch( + "spinetoolbox.ui_main.QSettings.sync" + ), mock.patch("PySide6.QtWidgets.QFileDialog.getExistingDirectory") as mock_dir_getter: + mock_dir_getter.return_value = self._temp_dir.name + self.toolbox.new_project() + add_dc_trough_undo_stack(self.toolbox, "DC1") + self.toolbox.save_project() + self.assertTrue(self.toolbox.undo_stack.isClean()) + self.assertEqual(self.toolbox.project().get_item("DC1").name, "DC1") + add_dc_trough_undo_stack(self.toolbox, "DC2") + self.assertFalse(self.toolbox.undo_stack.isClean()) + with mock.patch("spinetoolbox.ui_main.QSettings.value") as mock_qsettings_value: + # Make sure that the test uses LocalSpineEngineManager + mock_qsettings_value.side_effect = qsettings_value_side_effect + # Selecting cancel on the project close confirmation + with mock.patch.object(QMessageBox, "exec", return_value=QMessageBox.Cancel): + self.assertFalse(self.toolbox.close_project()) + mock_qsettings_value.assert_called() + with mock.patch("spinetoolbox.ui_main.ToolboxUI.save_project"), mock.patch( + "spinetoolbox.project.create_dir" + ), mock.patch("spinetoolbox.project_item.project_item.create_dir"), mock.patch( + "spinetoolbox.ui_main.ToolboxUI.update_recent_projects" + ), mock.patch.object( + QMessageBox, "exec", return_value=QMessageBox.Cancel + ): + # Selecting cancel on the project close confirmation + with mock.patch("spinetoolbox.ui_main.ToolboxUI.add_warning_message") as warning_msg: + # trying to open the same project but selecting cancel when asked about unsaved changes + self.assertFalse(self.toolbox.open_project(self._temp_dir.name)) + warning_msg.assert_called_with( + f"Cancelled opening project {self._temp_dir.name}. Current project has unsaved changes." + ) + self.assertIsNotNone(self.toolbox.project()) + self.assertEqual(self.toolbox.project().get_item("DC1").name, "DC1") + self.assertEqual(self.toolbox.project().get_item("DC2").name, "DC2") + def test_close_project(self): self.assertIsNone(self.toolbox.project()) self.assertTrue(self.toolbox.close_project()) @@ -284,102 +252,69 @@ def test_close_project(self): mock_qsettings_value.assert_called() self.assertIsNone(self.toolbox.project()) - def test_selection_in_project_item_list_1(self): - """Test item selection in treeView_project. Simulates a mouse click on a Data Store item - in the project Tree View widget (i.e. the project item list). - """ - with TemporaryDirectory() as project_dir: - create_project(self.toolbox, project_dir) - ds1 = "DS1" - add_ds(self.toolbox.project(), self.toolbox.item_factories, ds1) - n_items = self.toolbox.project_item_model.n_items() - self.assertEqual(n_items, 1) # Check that the project contains one item - ds_ind = self.toolbox.project_item_model.find_item(ds1) - tv = self.toolbox.ui.treeView_project - tv.expandAll() # NOTE: mouseClick does not work without this - tv_sm = tv.selectionModel() - # Scroll to item -> get rectangle -> click - tv.scrollTo(ds_ind) # Make sure the item is 'visible' - ds1_rect = tv.visualRect(ds_ind) - # logging.debug("viewport geometry:{0}".format(tv.viewport().geometry())) # this is pos() and size() combined - # logging.debug("item rect:{0}".format(ds1_rect)) - # Simulate mouse click on selected item - QTest.mouseClick(tv.viewport(), Qt.LeftButton, Qt.NoModifier, ds1_rect.center()) - self.assertTrue(tv_sm.isSelected(ds_ind)) - self.assertEqual(tv_sm.currentIndex(), ds_ind) - self.assertEqual(1, len(tv_sm.selectedIndexes())) - # Active project item should be DS1 - self.assertEqual( - self.toolbox.project_item_model.item(ds_ind).project_item, self.toolbox.active_project_item - ) - - def test_selection_in_project_item_list_2(self): - """Test item selection in treeView_project. Simulates mouse clicks on a Data Store items. - Click on a project item and then on another project item. - """ - with TemporaryDirectory() as project_dir: - create_project(self.toolbox, project_dir) - ds1 = "DS1" - ds2 = "DS2" - add_ds(self.toolbox.project(), self.toolbox.item_factories, ds1) - add_ds(self.toolbox.project(), self.toolbox.item_factories, ds2) - n_items = self.toolbox.project_item_model.n_items() - self.assertEqual(n_items, 2) - ds1_ind = self.toolbox.project_item_model.find_item(ds1) - ds2_ind = self.toolbox.project_item_model.find_item(ds2) - tv = self.toolbox.ui.treeView_project - tv.expandAll() - tv_sm = tv.selectionModel() - # Scroll to item -> get rectangle -> click - tv.scrollTo(ds1_ind) - ds1_rect = tv.visualRect(ds1_ind) - QTest.mouseClick(tv.viewport(), Qt.LeftButton, Qt.NoModifier, ds1_rect.center()) - # Scroll to item -> get rectangle -> click - tv.scrollTo(ds2_ind) - ds2_rect = tv.visualRect(ds2_ind) - QTest.mouseClick(tv.viewport(), Qt.LeftButton, Qt.NoModifier, ds2_rect.center()) - self.assertTrue(tv_sm.isSelected(ds2_ind)) - self.assertEqual(tv_sm.currentIndex(), ds2_ind) - self.assertEqual(1, len(tv_sm.selectedIndexes())) - # Active project item should be DS2 - self.assertEqual( - self.toolbox.project_item_model.item(ds2_ind).project_item, self.toolbox.active_project_item - ) - - def test_selection_in_project_item_list_3(self): - """Test item selection in treeView_project. Simulates mouse clicks on a Data Store items. - Test multiple selection (Ctrl-pressed) with two Data Store items. - """ - with TemporaryDirectory() as project_dir: - create_project(self.toolbox, project_dir) - ds1 = "DS1" - ds2 = "DS2" - add_ds(self.toolbox.project(), self.toolbox.item_factories, ds1) - add_ds(self.toolbox.project(), self.toolbox.item_factories, ds2) - n_items = self.toolbox.project_item_model.n_items() - self.assertEqual(n_items, 2) - ds1_ind = self.toolbox.project_item_model.find_item(ds1) - ds2_ind = self.toolbox.project_item_model.find_item(ds2) - tv = self.toolbox.ui.treeView_project - tv.expandAll() - tv_sm = tv.selectionModel() - # Scroll to item -> get rectangle -> click - tv.scrollTo(ds1_ind) - ds1_rect = tv.visualRect(ds1_ind) - QTest.mouseClick(tv.viewport(), Qt.LeftButton, Qt.ControlModifier, ds1_rect.center()) - # Scroll to item -> get rectangle -> click - tv.scrollTo(ds2_ind) - ds2_rect = tv.visualRect(ds2_ind) - QTest.mouseClick(tv.viewport(), Qt.LeftButton, Qt.ControlModifier, ds2_rect.center()) - # Both items should be selected, but we don't know which one is current as QGraphicsScene.selecteItems() is not sorted - self.assertTrue(tv_sm.isSelected(ds1_ind)) - self.assertTrue(tv_sm.isSelected(ds2_ind)) - self.assertEqual(2, len(tv_sm.selectedIndexes())) - # There should also be 2 items selected in the Design View - n_selected_items_in_design_view = len(self.toolbox.ui.graphicsView.scene().selectedItems()) - self.assertEqual(2, n_selected_items_in_design_view) - # Active project item should be None - self.assertIsNone(self.toolbox.active_project_item) + def test_show_project_or_item_context_menu(self): + self._temp_dir = TemporaryDirectory() + with mock.patch("spinetoolbox.ui_main.QSettings.setValue") as mock_set_value, mock.patch( + "spinetoolbox.ui_main.QSettings.sync" + ) as mock_sync, mock.patch("PySide6.QtWidgets.QFileDialog.getExistingDirectory") as mock_dir_getter: + mock_dir_getter.return_value = self._temp_dir.name + self.toolbox.new_project() + mock_set_value.assert_called() + mock_sync.assert_called() + mock_dir_getter.assert_called() + add_dc(self.toolbox.project(), self.toolbox.item_factories, "DC") + # mocking "PySide6.QtWidgets.QMenu.exec directly doesn't work because QMenu.exec is overloaded! + with mock.patch("spinetoolbox.ui_main.QMenu") as mock_qmenu: + mock_qmenu.side_effect = MockQMenu + self.toolbox.show_project_or_item_context_menu(QPoint(0, 0), None) + with mock.patch("spinetoolbox.ui_main.QMenu") as mock_qmenu: + mock_qmenu.side_effect = MockQMenu + dc = self.toolbox.project().get_item("DC") + self.toolbox.show_project_or_item_context_menu(QPoint(0, 0), dc) + + def test_refresh_edit_action_states(self): + self.toolbox.refresh_edit_action_states() + # No project + self.assertFalse(self.toolbox.ui.actionCopy.isEnabled()) + self.assertFalse(self.toolbox.ui.actionPaste.isEnabled()) + self.assertFalse(self.toolbox.ui.actionPasteAndDuplicateFiles.isEnabled()) + self.assertFalse(self.toolbox.ui.actionDuplicate.isEnabled()) + self.assertFalse(self.toolbox.ui.actionDuplicateAndDuplicateFiles.isEnabled()) + self.assertFalse(self.toolbox.ui.actionRemove.isEnabled()) + self.assertFalse(self.toolbox.ui.actionRemove_all.isEnabled()) + # Make project + self._temp_dir = TemporaryDirectory() + with mock.patch("spinetoolbox.ui_main.QSettings.setValue") as mock_set_value, mock.patch( + "spinetoolbox.ui_main.QSettings.sync" + ) as mock_sync, mock.patch("PySide6.QtWidgets.QFileDialog.getExistingDirectory") as mock_dir_getter: + mock_dir_getter.return_value = self._temp_dir.name + self.toolbox.new_project() + mock_set_value.assert_called() + mock_sync.assert_called() + mock_dir_getter.assert_called() + add_dc(self.toolbox.project(), self.toolbox.item_factories, "DC") + dc = self.toolbox.project().get_item("DC") + icon = dc.get_icon() + icon.setSelected(True) + with mock.patch("spinetoolbox.ui_main.QApplication.clipboard") as mock_clipboard: + self.toolbox.refresh_edit_action_states() + mock_clipboard.assert_called() + self.assertTrue(self.toolbox.ui.actionCopy.isEnabled()) + self.assertFalse(self.toolbox.ui.actionPaste.isEnabled()) + self.assertFalse(self.toolbox.ui.actionPasteAndDuplicateFiles.isEnabled()) + self.assertTrue(self.toolbox.ui.actionDuplicate.isEnabled()) + self.assertTrue(self.toolbox.ui.actionDuplicateAndDuplicateFiles.isEnabled()) + self.assertTrue(self.toolbox.ui.actionRemove.isEnabled()) + self.assertTrue(self.toolbox.ui.actionRemove_all.isEnabled()) + # Cover enable_edit_actions() + self.toolbox.enable_edit_actions() + self.assertTrue(self.toolbox.ui.actionCopy.isEnabled()) + self.assertTrue(self.toolbox.ui.actionPaste.isEnabled()) + self.assertTrue(self.toolbox.ui.actionPasteAndDuplicateFiles.isEnabled()) + self.assertTrue(self.toolbox.ui.actionDuplicate.isEnabled()) + self.assertTrue(self.toolbox.ui.actionDuplicateAndDuplicateFiles.isEnabled()) + self.assertTrue(self.toolbox.ui.actionRemove.isEnabled()) + self.assertTrue(self.toolbox.ui.actionRemove_all.isEnabled()) def test_selection_in_design_view_1(self): """Test item selection in Design View. Simulates mouse click on a Data Connection item. @@ -389,21 +324,16 @@ def test_selection_in_design_view_1(self): create_project(self.toolbox, self._temp_dir.name) dc1 = "DC1" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 1) # Check that the project contains one item - dc1_index = self.toolbox.project_item_model.find_item(dc1) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) dc1_center_point = self.find_click_point_of_pi(dc1_item, gv) # Center point in graphics view viewport coords. # Simulate mouse click on Data Connection in Design View QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point) - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - self.assertTrue(tv_sm.isSelected(dc1_index)) - self.assertEqual(dc1_index, tv_sm.currentIndex()) - self.assertEqual(1, len(tv_sm.selectedIndexes())) self.assertEqual(1, len(gv.scene().selectedItems())) # Active project item should be DC1 - self.assertEqual(self.toolbox.project_item_model.item(dc1_index).project_item, self.toolbox.active_project_item) + self.assertEqual(self.toolbox.project().get_item(dc1), self.toolbox.active_project_item) def test_selection_in_design_view_2(self): """Test item selection in Design View. @@ -415,26 +345,20 @@ def test_selection_in_design_view_2(self): dc2 = "DC2" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) add_dc(self.toolbox.project(), self.toolbox.item_factories, dc2, x=100, y=100) - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 2) # Check the number of project items - dc1_index = self.toolbox.project_item_model.find_item(dc1) - dc2_index = self.toolbox.project_item_model.find_item(dc2) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item - dc2_item = self.toolbox.project_item_model.item(dc2_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) + dc2_item = self.toolbox.project().get_item(dc2) dc1_center_point = self.find_click_point_of_pi(dc1_item, gv) dc2_center_point = self.find_click_point_of_pi(dc2_item, gv) # Mouse click on dc1 QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point) # Then mouse click on dc2 QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc2_center_point) - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - self.assertTrue(tv_sm.isSelected(dc2_index)) - self.assertEqual(dc2_index, tv_sm.currentIndex()) - self.assertEqual(1, len(tv_sm.selectedIndexes())) self.assertEqual(1, len(gv.scene().selectedItems())) # Active project item should be DC2 - self.assertEqual(self.toolbox.project_item_model.item(dc2_index).project_item, self.toolbox.active_project_item) + self.assertEqual(self.toolbox.project().get_item(dc2), self.toolbox.active_project_item) def test_selection_in_design_view_3(self): """Test item selection in Design View. @@ -444,18 +368,13 @@ def test_selection_in_design_view_3(self): create_project(self.toolbox, self._temp_dir.name) dc1 = "DC1" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) - dc1_index = self.toolbox.project_item_model.find_item(dc1) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) dc1_center_point = self.find_click_point_of_pi(dc1_item, gv) # Mouse click on dc1 QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point) # Then mouse click somewhere else in Design View (not on project item) QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, QPoint(1, 1)) - # Treeview current index should be dc1_index - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - self.assertEqual(dc1_index, tv_sm.currentIndex()) - self.assertEqual(0, len(tv_sm.selectedIndexes())) # No items in pi list should be selected self.assertEqual(0, len(gv.scene().selectedItems())) # No items in design view should be selected # Active project item should be None self.assertIsNone(self.toolbox.active_project_item) @@ -470,13 +389,11 @@ def test_selection_in_design_view_4(self): dc2 = "DC2" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) add_dc(self.toolbox.project(), self.toolbox.item_factories, dc2, x=100, y=100) - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 2) # Check the number of project items - dc1_index = self.toolbox.project_item_model.find_item(dc1) - dc2_index = self.toolbox.project_item_model.find_item(dc2) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item - dc2_item = self.toolbox.project_item_model.item(dc2_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) + dc2_item = self.toolbox.project().get_item(dc2) # Add link between dc1 and dc2 gv.add_link(dc1_item.get_icon().conn_button("bottom"), dc2_item.get_icon().conn_button("bottom")) # Find link @@ -488,13 +405,6 @@ def test_selection_in_design_view_4(self): link_center_point = self.find_click_point_of_link(links[0], gv) # Mouse click on link QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, link_center_point) - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - # Check that dc1 is NOT selected - self.assertFalse(tv_sm.isSelected(dc1_index)) - # Check that dc2 is NOT selected - self.assertFalse(tv_sm.isSelected(dc2_index)) - # No items should be selected in the tree view - self.assertEqual(0, len(tv_sm.selectedIndexes())) # One item should be selected in Design View (the Link) selected_items = gv.scene().selectedItems() self.assertEqual(1, len(selected_items)) @@ -513,13 +423,11 @@ def test_selection_in_design_view_5(self): dc2 = "DC2" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) add_dc(self.toolbox.project(), self.toolbox.item_factories, dc2, x=100, y=100) - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 2) # Check the number of project items - dc1_index = self.toolbox.project_item_model.find_item(dc1) - dc2_index = self.toolbox.project_item_model.find_item(dc2) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item - dc2_item = self.toolbox.project_item_model.item(dc2_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) + dc2_item = self.toolbox.project().get_item(dc2) # Add link between dc1 and dc2 gv.add_link(dc1_item.get_icon().conn_button("bottom"), dc2_item.get_icon().conn_button("bottom")) # Find link @@ -534,13 +442,6 @@ def test_selection_in_design_view_5(self): QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, dc1_center_point) # Mouse click on link QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.NoModifier, link_center_point) - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - # Check that dc1 is NOT selected - self.assertFalse(tv_sm.isSelected(dc1_index)) - # Check that dc2 is NOT selected - self.assertFalse(tv_sm.isSelected(dc2_index)) - # No items should be selected in the tree view - self.assertEqual(0, len(tv_sm.selectedIndexes())) # One item should be selected in Design View (the Link) selected_items = gv.scene().selectedItems() self.assertEqual(1, len(selected_items)) @@ -560,24 +461,17 @@ def test_selection_in_design_view_6(self): dc2 = "DC2" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1, x=0, y=0) add_dc(self.toolbox.project(), self.toolbox.item_factories, dc2, x=100, y=100) - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 2) # Check the number of project items - dc1_index = self.toolbox.project_item_model.find_item(dc1) - dc2_index = self.toolbox.project_item_model.find_item(dc2) gv = self.toolbox.ui.graphicsView - dc1_item = self.toolbox.project_item_model.item(dc1_index).project_item - dc2_item = self.toolbox.project_item_model.item(dc2_index).project_item + dc1_item = self.toolbox.project().get_item(dc1) + dc2_item = self.toolbox.project().get_item(dc2) dc1_center_point = self.find_click_point_of_pi(dc1_item, gv) dc2_center_point = self.find_click_point_of_pi(dc2_item, gv) # Mouse click on dc1 QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.ControlModifier, dc1_center_point) # Then mouse click on dc2 QTest.mouseClick(gv.viewport(), Qt.LeftButton, Qt.ControlModifier, dc2_center_point) - tv_sm = self.toolbox.ui.treeView_project.selectionModel() - self.assertEqual(2, len(tv_sm.selectedIndexes())) - self.assertTrue(tv_sm.isSelected(dc1_index)) - self.assertTrue(tv_sm.isSelected(dc2_index)) - # NOTE: No test for tv_sm current index here! self.assertEqual(2, len(gv.scene().selectedItems())) # Active project item should be None self.assertIsNone(self.toolbox.active_project_item) @@ -588,7 +482,7 @@ def test_drop_invalid_drag_on_design_view(self): pos = QPoint(0, 0) event = QDropEvent(pos, Qt.CopyAction, mime_data, Qt.NoButton, Qt.NoModifier) with mock.patch( - 'PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source' + "PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source" ) as mock_drop_event_source, mock.patch.object(self.toolbox, "project"), mock.patch.object( self.toolbox, "show_add_project_item_form" ) as mock_show_add_project_item_form: @@ -607,11 +501,11 @@ def test_drop_project_item_on_design_view(self): pos = gv.mapFromScene(scene_pos) event = QDropEvent(pos, Qt.CopyAction, mime_data, Qt.NoButton, Qt.NoModifier) with mock.patch( - 'PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source' + "PySide6.QtWidgets.QGraphicsSceneDragDropEvent.source" ) as mock_drop_event_source, mock.patch.object(self.toolbox, "project"), mock.patch.object( self.toolbox, "show_add_project_item_form" ) as mock_show_add_project_item_form: - mock_drop_event_source.return_value = ProjectItemDragMixin() + mock_drop_event_source.return_value = MockDraggableButton() gv.dropEvent(event) mock_show_add_project_item_form.assert_called_once() mock_show_add_project_item_form.assert_called_with(item_type, scene_pos.x(), scene_pos.y(), spec="spec") @@ -626,28 +520,25 @@ def test_remove_item(self): dc1 = "DC1" add_dc(self.toolbox.project(), self.toolbox.item_factories, dc1) # Check the size of project item model - n_items = self.toolbox.project_item_model.n_items() + n_items = self.toolbox.project().n_items self.assertEqual(n_items, 1) # Check DAG handler dags = [dag for dag in self.toolbox.project()._dag_iterator()] self.assertEqual(1, len(dags)) # Number of DAGs (DiGraph objects) in project self.assertEqual(1, len(dags[0].nodes())) # Number of nodes in the DiGraph # Check number of items in Design View - items_in_design_view = self.toolbox.ui.graphicsView.scene().items() - n_items_in_design_view = len([item for item in items_in_design_view if isinstance(item, ProjectItemIcon)]) - self.assertEqual(n_items_in_design_view, 1) + item_icons = self.toolbox.ui.graphicsView.scene().project_item_icons() + self.assertEqual(len(item_icons), 1) + item_icons[0].setSelected(True) # Select item on Design View # NOW REMOVE DC1 - dc1_ind = self.toolbox.project_item_model.find_item(dc1) - self.toolbox.ui.treeView_project.selectionModel().select(dc1_ind, QItemSelectionModel.ClearAndSelect) with mock.patch.object(spinetoolbox.ui_main.QMessageBox, "exec") as mock_message_box_exec: mock_message_box_exec.return_value = QMessageBox.StandardButton.Ok self.toolbox.ui.actionRemove.trigger() - self.assertEqual(self.toolbox.project_item_model.n_items(), 0) # Check the number of project items + self.assertEqual(self.toolbox.project().n_items, 0) # Check the number of project items dags = [dag for dag in self.toolbox.project()._dag_iterator()] self.assertEqual(0, len(dags)) # Number of DAGs (DiGraph) objects in project - items_in_design_view = self.toolbox.ui.graphicsView.scene().items() - n_items_in_design_view = len([item for item in items_in_design_view if isinstance(item, ProjectItemIcon)]) - self.assertEqual(n_items_in_design_view, 0) + item_icons = self.toolbox.ui.graphicsView.scene().project_item_icons() + self.assertEqual(len(item_icons), 0) def test_add_and_remove_specification(self): """Tests that adding and removing a specification @@ -752,8 +643,9 @@ def test_copy_project_item_to_clipboard(self): self._temp_dir = TemporaryDirectory() create_project(self.toolbox, self._temp_dir.name) add_dc(self.toolbox.project(), self.toolbox.item_factories, "data_connection") - item_index = self.toolbox.project_item_model.find_item("data_connection") - self.toolbox.ui.treeView_project.selectionModel().select(item_index, QItemSelectionModel.Select) + items_on_design_view = self.toolbox.ui.graphicsView.scene().project_item_icons() + self.assertEqual(len(items_on_design_view), 1) + items_on_design_view[0].setSelected(True) self.toolbox.ui.actionCopy.triggered.emit() # noinspection PyArgumentList clipboard = QApplication.clipboard() @@ -768,34 +660,35 @@ def test_paste_project_item_from_clipboard(self): self._temp_dir = TemporaryDirectory() create_project(self.toolbox, self._temp_dir.name) add_dc(self.toolbox.project(), self.toolbox.item_factories, "data_connection") - self.assertEqual(self.toolbox.project_item_model.n_items(), 1) - item_index = self.toolbox.project_item_model.find_item("data_connection") - self.toolbox.ui.treeView_project.selectionModel().select(item_index, QItemSelectionModel.Select) + self.assertEqual(self.toolbox.project().n_items, 1) + items_on_design_view = self.toolbox.ui.graphicsView.scene().project_item_icons() + self.assertEqual(len(items_on_design_view), 1) + items_on_design_view[0].setSelected(True) self.toolbox.ui.actionCopy.triggered.emit() self.toolbox.ui.actionPaste.triggered.emit() - self.assertEqual(self.toolbox.project_item_model.n_items(), 2) - new_item_index = self.toolbox.project_item_model.find_item("data_connection (1)") - self.assertIsNotNone(new_item_index) + self.assertEqual(self.toolbox.project().n_items, 2) + new_item = self.toolbox.project().get_item("data_connection (1)") + self.assertIsInstance(new_item, ProjectItem) def test_duplicate_project_item(self): self._temp_dir = TemporaryDirectory() create_project(self.toolbox, self._temp_dir.name) add_dc(self.toolbox.project(), self.toolbox.item_factories, "data_connection") - self.assertEqual(self.toolbox.project_item_model.n_items(), 1) - item_index = self.toolbox.project_item_model.find_item("data_connection") - self.toolbox.ui.treeView_project.selectionModel().select(item_index, QItemSelectionModel.Select) + self.assertEqual(self.toolbox.project().n_items, 1) + items_on_design_view = self.toolbox.ui.graphicsView.scene().project_item_icons() + self.assertEqual(len(items_on_design_view), 1) + items_on_design_view[0].setSelected(True) with mock.patch("spinetoolbox.project_item.project_item.create_dir"): self.toolbox.ui.actionDuplicate.triggered.emit() - self.assertEqual(self.toolbox.project_item_model.n_items(), 2) - new_item_index = self.toolbox.project_item_model.find_item("data_connection (1)") - self.assertIsNotNone(new_item_index) + self.assertEqual(self.toolbox.project().n_items, 2) + new_item = self.toolbox.project().get_item("data_connection (1)") + self.assertIsInstance(new_item, ProjectItem) def test_persistent_console_requested(self): self._temp_dir = TemporaryDirectory() create_project(self.toolbox, self._temp_dir.name) add_tool(self.toolbox.project(), self.toolbox.item_factories, "tool") - index = self.toolbox.project_item_model.find_item("tool") - item = self.toolbox.project_item_model.item(index).project_item + item = self.toolbox.project().get_item("tool") filter_id = "" key = ("too", "") language = "julia" @@ -811,8 +704,7 @@ def test_filtered_persistent_consoles_requested(self): self._temp_dir = TemporaryDirectory() create_project(self.toolbox, self._temp_dir.name) add_tool(self.toolbox.project(), self.toolbox.item_factories, "tool") - index = self.toolbox.project_item_model.find_item("tool") - item = self.toolbox.project_item_model.item(index).project_item + item = self.toolbox.project().get_item("tool") language = "julia" self.toolbox.refresh_active_elements(item, None, {"tool"}) self.toolbox._setup_persistent_console(item, "filter1", ("tool", "filter1"), language) @@ -921,6 +813,11 @@ def _tasks_before_exit_scenario_6(key, defaultValue="2"): return "automatic" +class MockDraggableButton(ProjectItemDragMixin, NiceButton): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + class TestToolboxUIWithTestSettings(unittest.TestCase): @classmethod def setUpClass(cls): @@ -971,5 +868,10 @@ def toolbox_with_settings(settings_dict): clean_up_toolbox(toolbox) -if __name__ == '__main__': +class MockQMenu(QMenu): + def exec(self, pos): + return True + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_execution_managers.py b/tests/test_execution_managers.py index 5a68f82aa..e52d66fdc 100644 --- a/tests/test_execution_managers.py +++ b/tests/test_execution_managers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -8,9 +9,8 @@ # 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 . ###################################################################################################################### -""" -Unit tests for ``execution_managers`` module. -""" + +"""Unit tests for ``execution_managers`` module.""" import sys import unittest from unittest.mock import MagicMock diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 4a2e3a5dd..664c16d8b 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,19 +10,15 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the helpers module. -""" +"""Unit tests for the helpers module.""" import json import re from pathlib import Path from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock, patch - from PySide6.QtCore import QSettings from PySide6.QtWidgets import QApplication, QLineEdit - from spine_engine.load_project_items import load_item_specification_factories from spinetoolbox.config import PROJECT_FILENAME, PROJECT_LOCAL_DATA_DIR_NAME, PROJECT_LOCAL_DATA_FILENAME from spinetoolbox.helpers import ( @@ -35,10 +32,13 @@ format_string_list, get_datetime, interpret_icon_id, + list_to_rich_text, load_specification_from_file, make_icon_id, + plain_to_tool_tip, recursive_overwrite, rename_dir, + plain_to_rich, rows_to_row_count_tuples, select_julia_executable, select_julia_project, @@ -355,5 +355,28 @@ def test_replaces_br_by_newline(self): self.assertEqual(tag_filter.drain(), "First line\nsecond line") +class TestPlainToRich(unittest.TestCase): + def test_final_string_is_rich_text(self): + self.assertEqual(plain_to_rich(""), "") + self.assertEqual( + plain_to_rich("Just a plain string making its way to rich."), + "Just a plain string making its way to rich.", + ) + + +class TestListToRichText(unittest.TestCase): + def test_makes_rich_text(self): + self.assertEqual(list_to_rich_text([]), "") + self.assertEqual(list_to_rich_text(["single"]), "single") + self.assertEqual(list_to_rich_text(["first", "second"]), "first
second
") + + +class TestPlainToToolTip(unittest.TestCase): + def test_makes_tool_tips(self): + self.assertIsNone(plain_to_tool_tip(None)) + self.assertIsNone(plain_to_tool_tip("")) + self.assertEqual(plain_to_tool_tip("Is not None."), plain_to_rich("Is not None.")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_load_project_items.py b/tests/test_load_project_items.py index 888a2dd6e..1830af44c 100644 --- a/tests/test_load_project_items.py +++ b/tests/test_load_project_items.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the :module:`spinetoolbox.load_project_items` module. -""" +"""Unit tests for the :module:`spinetoolbox.load_project_items` module.""" import unittest from PySide6.QtWidgets import QApplication from spinetoolbox.load_project_items import load_project_items @@ -25,24 +24,10 @@ def setUpClass(cls): QApplication() def test_load_project_items_finds_all_default_items(self): - categories, factories = load_project_items("spine_items") - expected_categories = { - "Data Connection": "Data Connections", - "Data Transformer": "Manipulators", - "Merger": "Manipulators", - "Data Store": "Data Stores", - "Importer": "Importers", - "Exporter": "Exporters", - "Tool": "Tools", - "View": "Views", - } - self.assertEqual(categories, expected_categories) - self.assertEqual(len(factories), len(expected_categories)) - for item_type in expected_categories: - self.assertIn(item_type, factories) + factories = load_project_items("spine_items") for factory in factories.values(): self.assertTrue(issubclass(factory, ProjectItemFactory)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 253d974ea..270ea5b39 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,19 +10,14 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the plotting module. -""" - +"""Unit tests for the plotting module.""" import unittest from contextlib import contextmanager -from unittest.mock import Mock, MagicMock, patch - +from unittest.mock import patch import numpy from PySide6.QtCore import QModelIndex, QItemSelectionModel, QObject -from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtWidgets import QApplication from matplotlib.gridspec import GridSpec - from spinedb_api import ( DateTime, Map, @@ -31,8 +27,8 @@ TimePattern, Array, ) -from spinetoolbox.helpers import signal_waiter from spinetoolbox.plotting import ( + plot_parameter_table_selection, PlottingError, convert_indexed_value_to_tree, TreeNode, @@ -47,59 +43,27 @@ add_row_to_exception, IndexName, ) -from spinetoolbox.spine_db_editor.widgets.spine_db_editor import SpineDBEditor -from tests.mock_helpers import TestSpineDBManager - +from tests.spine_db_editor.helpers import TestBase -class TestBase(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def setUp(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.restore_ui"), patch( - "spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.show" - ): - mock_settings = Mock() - mock_settings.value.side_effect = lambda *args, **kwargs: 0 - self._db_mngr = TestSpineDBManager(mock_settings, None) - logger = MagicMock() - self._db_map = self._db_mngr.get_db_map("sqlite://", logger, codename="test database", create=True) - self._db_editor = SpineDBEditor(self._db_mngr, {"sqlite://": "test database"}) - - def tearDown(self): - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.QMessageBox") as message_box: - message_box.exec.return_value = QMessageBox.StandardButton.Ok - with signal_waiter(self._db_mngr.session_rolled_back) as waiter: - self._db_editor.rollback_session() - if message_box.exec.call_count > 0: - waiter.wait() - with patch("spinetoolbox.spine_db_editor.widgets.spine_db_editor.SpineDBEditor.save_window_state"), patch( - "spinetoolbox.spine_db_manager.QMessageBox" - ): - self._db_editor.close() - while not self._db_map.connection.closed: - QApplication.processEvents() - self._db_mngr.clean_up() - self._db_editor.deleteLater() - self._db_editor = None +class TestPlotPivotTableSelection(TestBase): def _add_object_parameter_values(self, values): - self._db_mngr.add_object_classes({self._db_map: [{"name": "class"}]}) + self._db_mngr.add_entity_classes({self._db_map: [{"name": "class", "id": 1}]}) self._db_mngr.add_parameter_definitions( - {self._db_map: [{"object_class_id": 1, "name": name} for name in values]} + {self._db_map: [{"entity_class_id": 1, "name": name, "id": i + 1} for i, name in enumerate(values)]} ) object_count = max(len(x) for x in values.values()) - self._db_mngr.add_objects({self._db_map: [{"class_id": 1, "name": f"o{i + 1}"} for i in range(object_count)]}) + self._db_mngr.add_entities( + {self._db_map: [{"class_id": 1, "name": f"o{i + 1}", "id": i + 1} for i in range(object_count)]} + ) db_values = { name: [(value, type_) for value, type_ in map(to_database, value_list)] for name, value_list in values.items() } value_items = [ { - "object_class_id": 1, - "object_id": (i + 1), + "entity_class_id": 1, + "entity_id": (i + 1), "parameter_definition_id": param_i + 1, "alternative_id": 1, "type": type_, @@ -111,21 +75,19 @@ def _add_object_parameter_values(self, values): self._db_mngr.add_parameter_values({self._db_map: value_items}) def _select_object_class_in_tree_view(self): - object_tree_model = self._db_editor.ui.treeView_object.model() + object_tree_model = self._db_editor.ui.treeView_entity.model() root_index = object_tree_model.index(0, 0) if object_tree_model.canFetchMore(root_index): object_tree_model.fetchMore(root_index) self.assertEqual(object_tree_model.rowCount(root_index), 1) class_index = object_tree_model.index(0, 0, root_index) - refreshing_models = list(self._db_editor._parameter_models) + list(self._db_editor._parameter_value_models) + refreshing_models = [self._db_editor.parameter_value_model, self._db_editor.parameter_definition_model] with multi_signal_waiter([model.refreshed for model in refreshing_models]) as at_filter_refresh: - self._db_editor.ui.treeView_object.selectionModel().setCurrentIndex( + self._db_editor.ui.treeView_entity.selectionModel().setCurrentIndex( class_index, QItemSelectionModel.ClearAndSelect ) at_filter_refresh.wait() - -class TestPlotPivotTableSelection(TestBase): def _fill_pivot(self, values): self._add_object_parameter_values(values) self.assertEqual(self._db_editor.current_input_type, self._db_editor._PARAMETER_VALUE) @@ -152,7 +114,7 @@ def test_floats(self): selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | floats") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | floats") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "floats") legend = plot_widget.canvas.legend_axes.get_legend() @@ -175,7 +137,7 @@ def test_ints(self): selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | ints") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | ints") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "ints") legend = plot_widget.canvas.legend_axes.get_legend() @@ -201,7 +163,7 @@ def test_time_series(self): selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | series | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | series | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "t") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "series") legend = plot_widget.canvas.legend_axes.get_legend() @@ -230,11 +192,13 @@ def test_time_series(self): def test_row_filtering(self): self._fill_pivot({"floats": [1.1, 1.2, 1.3]}) model = self._db_editor.pivot_table_proxy - model.set_filter("class", {(self._db_map, 1), (self._db_map, 3)}) + id1 = self._db_map.get_entity_item(id=1)["id"] + id3 = self._db_map.get_entity_item(id=3)["id"] + model.set_filter("class", {(self._db_map, id1), (self._db_map, id3)}) selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | floats") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | floats") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "floats") legend = plot_widget.canvas.legend_axes.get_legend() @@ -252,11 +216,12 @@ def test_row_filtering(self): def test_column_filtering(self): self._fill_pivot({"floats": [1.1, 1.2, 1.3], "ints": [-3, -1, 2]}) model = self._db_editor.pivot_table_proxy - model.set_filter("parameter", {(self._db_map, 2)}) + p_id = self._db_map.get_parameter_definition_item(id=2)["id"] + model.set_filter("parameter", {(self._db_map, p_id)}) selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | ints") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | ints") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "ints") legend = plot_widget.canvas.legend_axes.get_legend() @@ -279,7 +244,7 @@ def test_multiple_columns_selected_plots_on_two_y_axes(self): selected_indexes = [model.index(row, column) for column in range(1, 3) for row in range(2, 5)] plot_widget = plot_pivot_table_selection(model, selected_indexes) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "floats") self.assertTrue(plot_widget.canvas.has_twinned_axes()) @@ -315,7 +280,7 @@ def test_x_column(self): selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | a-ints | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | a-ints | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "b-floats") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "a-ints") legend = plot_widget.canvas.legend_axes.get_legend() @@ -336,11 +301,12 @@ def test_hidden_x_column_should_disable_it(self): self._fill_pivot({"a-ints": [-3, -1, 2], "b-floats": [1.1, 1.2, 1.3]}) model = self._db_editor.pivot_table_proxy model.sourceModel().set_plot_x_column(2, True) - model.set_filter("parameter", {(self._db_map, 1)}) + p_id = self._db_map.get_parameter_definition_item(id=1)["id"] + model.set_filter("parameter", {(self._db_map, p_id)}) selection = self._select_column(1, model) plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | a-ints") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | a-ints") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "alternative_name") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "a-ints") legend = plot_widget.canvas.legend_axes.get_legend() @@ -368,7 +334,7 @@ def test_add_to_existing_plot(self): selection = [model.index(first_data_row + 1, 1)] plot_pivot_table_selection(model, selection, plot_widget) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | series | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | series | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "t") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "series") legend = plot_widget.canvas.legend_axes.get_legend() @@ -410,7 +376,7 @@ def test_simple_map(self): selection = [model.index(2, 1)] plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | maps | o1 | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | maps | o1 | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "x") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "maps") self.assertIsNone(plot_widget.canvas.legend_axes.get_legend()) @@ -440,7 +406,7 @@ def test_nested_map(self): selection = [model.index(2, 1)] plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | maps | o1 | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | maps | o1 | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "x") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "maps") legend = plot_widget.canvas.legend_axes.get_legend() @@ -500,7 +466,7 @@ def test_nested_map_containing_time_series(self): selection = [model.index(2, 1)] plot_widget = plot_pivot_table_selection(model, selection) try: - self.assertEqual(plot_widget.canvas.axes.get_title(), "test database | maps | o1 | Base") + self.assertEqual(plot_widget.canvas.axes.get_title(), "TestPlotPivotTableSelection_db | maps | o1 | Base") self.assertEqual(plot_widget.canvas.axes.get_xlabel(), "t") self.assertEqual(plot_widget.canvas.axes.get_ylabel(), "maps") legend = plot_widget.canvas.legend_axes.get_legend() @@ -959,18 +925,18 @@ def test_data_with_numeric_and_string_x_data_raises(self): XYData( x=[1.0, 2.0, 3.0], y=[5.0, 2.0, -1.0], - x_label=IndexName('x', 0), - y_label='', - data_index=['1d_map'], - index_names=[IndexName('parameter_name', 0)], + x_label=IndexName("x", 0), + y_label="", + data_index=["1d_map"], + index_names=[IndexName("parameter_name", 0)], ), XYData( - x=['t1', 't2'], + x=["t1", "t2"], y=[13.0, 7.0], - x_label=IndexName('x', 2), - y_label='', - data_index=['uneven_map', 'A1'], - index_names=[IndexName('parameter_name', 0), IndexName('x', 1)], + x_label=IndexName("x", 2), + y_label="", + data_index=["uneven_map", "A1"], + index_names=[IndexName("parameter_name", 0), IndexName("x", 1)], ), ] self.assertRaises(PlottingError, raise_if_incompatible_x, data_list) @@ -1023,5 +989,5 @@ def multi_signal_waiter(signals): waiter.deleteLater() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_graphics_items.py b/tests/test_project_item_icon.py similarity index 77% rename from tests/test_graphics_items.py rename to tests/test_project_item_icon.py index 848537a8b..8f95f825f 100644 --- a/tests/test_graphics_items.py +++ b/tests/test_project_item_icon.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,17 +10,15 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ``graphics_items`` module. -""" +"""Unit tests for ``project_item_icon`` module.""" import os.path from tempfile import TemporaryDirectory import unittest from unittest.mock import patch, MagicMock from PySide6.QtCore import QEvent, QPoint, Qt -from PySide6.QtGui import QColor +from PySide6.QtGui import QColor, QContextMenuEvent from PySide6.QtWidgets import QApplication, QGraphicsSceneMouseEvent -from spinedb_api import DiffDatabaseMapping, import_scenarios, import_tools +from spinedb_api import DatabaseMapping, import_scenarios from spine_engine.project_item.project_item_resource import database_resource from spinetoolbox.project_item_icon import ExclamationIcon, ProjectItemIcon, RankIcon from spinetoolbox.project_item.logging_connection import LoggingConnection @@ -92,6 +91,14 @@ def test_drag_icon(self): move_command = self._toolbox.undo_stack.command(0) self.assertIsInstance(move_command, MoveIconCommand) + def test_context_menu_event(self): + item = add_view(self._toolbox.project(), self._toolbox.item_factories, "View") + icon = item.get_icon() + with patch("spinetoolbox.ui_main.ToolboxUI.show_project_or_item_context_menu") as mock_show_menu: + mock_show_menu.return_value = True + icon.contextMenuEvent(QGraphicsSceneMouseEvent(QEvent.Type.ContextMenu)) + mock_show_menu.assert_called() + class TestExclamationIcon(unittest.TestCase): @classmethod @@ -173,16 +180,16 @@ def tearDown(self): def test_scenario_filter_gets_added_to_filter_model(self): url = "sqlite:///" + os.path.join(self._temp_dir.name, "db.sqlite") - db_map = DiffDatabaseMapping(url, create=True) + db_map = DatabaseMapping(url, create=True) import_scenarios(db_map, (("scenario", True),)) db_map.commit_session("Add test data.") - db_map.connection.close() + db_map.close() self._link.connection.receive_resources_from_source( [database_resource("provider", url, "my_database", filterable=True)] ) self._link.connection.refresh_resource_filter_model() filter_model = self._link.connection.resource_filter_model - self.assertEqual(filter_model.rowCount(), 2) + self.assertEqual(filter_model.rowCount(), 1) self.assertEqual(filter_model.columnCount(), 1) index = filter_model.index(0, 0) self.assertEqual(index.data(), "my_database") @@ -197,48 +204,26 @@ def test_scenario_filter_gets_added_to_filter_model(self): self.assertEqual(scenario_item.index().data(), "Select all") scenario_item = scenario_title_item.child(1, 0) self.assertEqual(scenario_item.index().data(), "scenario") + alternative_title_item = root_item.child(1, 0) + self.assertEqual(alternative_title_item.index().data(), "Alternative filter") + self.assertEqual(alternative_title_item.rowCount(), 2) + self.assertEqual(alternative_title_item.columnCount(), 1) + alternative_item = alternative_title_item.child(0, 0) + self.assertEqual(alternative_item.index().data(), "Select all") + alternative_item = alternative_title_item.child(1, 0) + self.assertEqual(alternative_item.index().data(), "Base") scenario_index = filter_model.indexFromItem(scenario_item) self.assertEqual(self._link.connection.online_filters("my_database", "scenario_filter"), {"scenario": True}) filter_model.setData(scenario_index, Qt.CheckState.Unchecked.value, role=Qt.ItemDataRole.CheckStateRole) self.assertEqual(self._link.connection.online_filters("my_database", "scenario_filter"), {"scenario": False}) - - def test_tool_filter_gets_added_to_filter_model(self): - url = "sqlite:///" + os.path.join(self._temp_dir.name, "db.sqlite") - db_map = DiffDatabaseMapping(url, create=True) - import_tools(db_map, ("tool",)) - db_map.commit_session("Add test data.") - db_map.connection.close() - self._link.connection.receive_resources_from_source( - [database_resource("provider", url, "my_database", filterable=True)] - ) - self._link.connection.refresh_resource_filter_model() - filter_model = self._link.connection.resource_filter_model - self.assertEqual(filter_model.rowCount(), 2) - self.assertEqual(filter_model.columnCount(), 1) - index = filter_model.index(0, 0) - self.assertEqual(index.data(), "my_database") - root_item = filter_model.itemFromIndex(index) - self.assertEqual(root_item.rowCount(), 2) - self.assertEqual(root_item.columnCount(), 1) - tool_title_item = root_item.child(1, 0) - self.assertEqual(tool_title_item.index().data(), "Tool filter") - self.assertEqual(tool_title_item.rowCount(), 2) - self.assertEqual(tool_title_item.columnCount(), 1) - tool_item = tool_title_item.child(0, 0) - self.assertEqual(tool_item.index().data(), "Select all") - tool_item = tool_title_item.child(1, 0) - self.assertEqual(tool_item.index().data(), "tool") - tool_index = filter_model.indexFromItem(tool_item) - self.assertEqual(self._link.connection.online_filters("my_database", "tool_filter"), {"tool": True}) - filter_model.setData(tool_index, Qt.CheckState.Unchecked.value, role=Qt.ItemDataRole.CheckStateRole) - self.assertEqual(self._link.connection.online_filters("my_database", "tool_filter"), {"tool": False}) + self.assertEqual(self._link.connection.online_filters("my_database", "alternative_filter"), {"Base": True}) def test_toggle_scenario_filter(self): url = "sqlite:///" + os.path.join(self._temp_dir.name, "db.sqlite") - db_map = DiffDatabaseMapping(url, create=True) + db_map = DatabaseMapping(url, create=True) import_scenarios(db_map, (("scenario", True),)) db_map.commit_session("Add test data.") - db_map.connection.close() + db_map.close() self._link.connection.receive_resources_from_source([database_resource("provider", url, filterable=True)]) self._link.connection.refresh_resource_filter_model() self.assertEqual(self._link.connection.online_filters(url, "scenario_filter"), {"scenario": True}) @@ -246,19 +231,6 @@ def test_toggle_scenario_filter(self): filter_model.set_online(url, "scenario_filter", {"scenario": False}) self.assertEqual(self._link.connection.online_filters(url, "scenario_filter"), {"scenario": False}) - def test_toggle_tool_filter(self): - url = "sqlite:///" + os.path.join(self._temp_dir.name, "db.sqlite") - db_map = DiffDatabaseMapping(url, create=True) - import_tools(db_map, ("tool",)) - db_map.commit_session("Add test data.") - db_map.connection.close() - self._link.connection.receive_resources_from_source([database_resource("provider", url, filterable=True)]) - self._link.connection.refresh_resource_filter_model() - self.assertEqual(self._link.connection.online_filters(url, "tool_filter"), {"tool": True}) - filter_model = self._link.connection.resource_filter_model - filter_model.set_online(url, "tool_filter", {"tool": False}) - self.assertEqual(self._link.connection.online_filters(url, "tool_filter"), {"tool": False}) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_resources/Project Directory/.spinetoolbox/project.json b/tests/test_resources/Project Directory/.spinetoolbox/project.json index 41e1ba08e..94dc9edc7 100644 --- a/tests/test_resources/Project Directory/.spinetoolbox/project.json +++ b/tests/test_resources/Project Directory/.spinetoolbox/project.json @@ -1,6 +1,6 @@ { "project": { - "version": 11, + "version": 13, "description": "Project for unit tests.", "specifications": { "Tool": [], diff --git a/tests/test_resources/basic_model_template.json b/tests/test_resources/basic_model_template.json new file mode 100644 index 000000000..4168bd7dc --- /dev/null +++ b/tests/test_resources/basic_model_template.json @@ -0,0 +1,18 @@ +{ + "objects": [ + ["model", "simple", null], + ["report", "report1", null], + ["stochastic_scenario", "realization", null], + ["stochastic_structure", "deterministic", null], + ["temporal_block", "flat", null] + ], + "relationships": [ + ["model__default_stochastic_structure", ["simple", "deterministic"]], + ["model__default_temporal_block", ["simple", "flat"]], + ["model__report", ["simple", "report1"]], + ["stochastic_structure__stochastic_scenario", ["deterministic", "realization"]] + ], + "object_parameter_values": [ + ["temporal_block", "flat", "resolution", {"data": "1D", "type": "duration"}, "Base"] + ] +} diff --git a/tests/test_resources/spineopt_template.json b/tests/test_resources/spineopt_template.json new file mode 100644 index 000000000..ada200723 --- /dev/null +++ b/tests/test_resources/spineopt_template.json @@ -0,0 +1,537 @@ +{ + "object_classes": [ + ["commodity", "A good or product that can be consumed, produced, traded. E.g., electricity, oil, gas, water...", 281473533932880], + ["connection", "A transfer of commodities between nodes. E.g. electricity line, gas pipeline...", 280378317271233], + ["investment_group", "A group of investments that need to be done together.", 281105609585860], + ["model", "An instance of SpineOpt, that specifies general parameters such as the temporal horizon.", 281107035648412], + ["node", "A universal aggregator of commodify flows over units and connections, with storage capabilities.", 280740554077951], + ["output", "A variable name from SpineOpt that can be included in a report.", 280743406202948], + ["report", "A results report from a particular SpineOpt run, including the value of specific variables.", 281108461711708], + ["settings", "Internal SpineOpt settings. We kindly advise not to mess with this one.", 280375465144798], + ["stochastic_scenario", "A scenario for stochastic optimisation in SpineOpt.", 280743389491710], + ["stochastic_structure", "A group of stochastic scenarios that represent a structure.", 281470681806146], + ["temporal_block", "A length of time with a particular resolution.", 280376891207703], + ["unit", "A conversion of one/many comodities between nodes.", 281470681805429], + ["user_constraint", "A generic data-driven custom constraint.", 281473533931636] + ], + "relationship_classes": [ + ["connection__from_node", ["connection", "node"], "Defines the `nodes` the `connection` can take input from, and holds most `connection_flow` variable specific parameters.", 280378317271897], + ["connection__from_node__investment_group", ["connection", "node", "investment_group"], "Indicates which connection capacities are included in the capacity invested available of an investment group"], + ["connection__from_node__user_constraint", ["connection", "node", "user_constraint"], "when specified this relationship allows the relevant flow connection flow variable to be included in the specified user constraint"], + ["connection__investment_group", ["connection", "investment_group"], "Indicates that a `connection` belongs in an `investment_group`."], + ["connection__investment_stochastic_structure", ["connection", "stochastic_structure"], "Defines the stochastic structure of the connections investments variable"], + ["connection__investment_temporal_block", ["connection", "temporal_block"], "Defines the temporal resolution of the connections investments variable"], + ["connection__node__node", ["connection", "node", "node"], "Holds parameters spanning multiple `connection_flow` variables to and from multiple `nodes`."], + ["connection__to_node__investment_group", ["connection", "node", "investment_group"], "Indicates which connection capacities are included in the capacity invested available of an investment group"], + ["connection__to_node", ["connection", "node"], "Defines the `nodes` the `connection` can output to, and holds most `connection_flow` variable specific parameters.", 280378317271898], + ["connection__to_node__user_constraint", ["connection", "node", "user_constraint"], "when specified this relationship allows the relevant flow connection flow variable to be included in the specified user constraint"], + ["connection__user_constraint", ["connection", "user_constraint"], "Relationship required to involve a connections investment variables in a user_constraint"], + ["model__default_investment_stochastic_structure", ["model", "stochastic_structure"], "Defines the default stochastic structure used for investment variables, which will be replaced by more specific definitions"], + ["model__default_investment_temporal_block", ["model", "temporal_block"], "Defines the default temporal block used for investment variables, which will be replaced by more specific definitions"], + ["model__default_stochastic_structure", ["model", "stochastic_structure"], "Defines the default stochastic structure used for model variables, which will be replaced by more specific definitions"], + ["model__default_temporal_block", ["model", "temporal_block"], "Defines the default temporal block used for model variables, which will be replaced by more specific definitions"], + ["model__report", ["model", "report"], "Determines which reports are written for each model and in turn, which outputs are written for each model"], + ["node__commodity", ["node", "commodity"], "Define a `commodity` for a `node`. Only a single `commodity` is permitted per `node`"], + ["node__investment_group", ["node", "investment_group"], "Indicates that a `node` belongs in a `investment_group`."], + ["node__investment_stochastic_structure", ["node", "stochastic_structure"], "defines the stochastic structure for node related investments, currently only storages"], + ["node__investment_temporal_block", ["node", "temporal_block"], "defines the temporal resolution for node related investments, currently only storages"], + ["node__node", ["node", "node"], "Holds parameters for direct interactions between two `nodes`, e.g. `node_state` diffusion coefficients."], + ["node__stochastic_structure", ["node", "stochastic_structure"], "Defines which specific `stochastic_structure` is used by the `node` and all `flow` variables associated with it. Only one `stochastic_structure` is permitted per `node`."], + ["node__temporal_block", ["node", "temporal_block"], "Defines the `temporal_blocks` used by the `node` and all the `flow` variables associated with it."], + ["node__user_constraint", ["node", "user_constraint"], "specifying this relationship allows a node's demand or node_state to be included in the specified user constraint"], + ["parent_stochastic_scenario__child_stochastic_scenario", ["stochastic_scenario", "stochastic_scenario"], "Defines the master stochastic direct acyclic graph, meaning how the `stochastic_scenarios` are related to each other."], + ["report__output", ["report", "output"], "Output object related to a report object are returned to the output database (if they appear in the model as variables)"], + ["stochastic_structure__stochastic_scenario", ["stochastic_structure", "stochastic_scenario"], "Defines which `stochastic_scenarios` are included in which `stochastic_structure`, and holds the parameters required for realizing the structure in combination with the `temporal_blocks`."], + ["unit__commodity", ["unit", "commodity"], "Holds parameters for `commodities` used by the `unit`."], + ["unit__from_node", ["unit", "node"], "Defines the `nodes` the `unit` can take input from, and holds most `unit_flow` variable specific parameters.", 281470681805657], + ["unit__from_node__investment_group", ["unit", "node", "investment_group"], "Indicates which unit capacities are included in the capacity invested available of an investment group"], + ["unit__from_node__user_constraint", ["unit", "node", "user_constraint"], "Defines which input `unit_flows` are included in the `user_constraint`, and holds their parameters."], + ["unit__investment_group", ["unit", "investment_group"], "Indicates that a `unit` belongs in an `investment_group`."], + ["unit__investment_stochastic_structure", ["unit", "stochastic_structure"], "Sets the stochastic structure for investment decisions - overrides `model__default_investment_stochastic_structure`."], + ["unit__investment_temporal_block", ["unit", "temporal_block"], "Sets the temporal resolution of investment decisions - overrides `model__default_investment_temporal_block`"], + ["unit__node__node", ["unit", "node", "node"], "Holds parameters spanning multiple `unit_flow` variables to and from multiple `nodes`."], + ["unit__to_node", ["unit", "node"], "Defines the `nodes` the `unit` can output to, and holds most `unit_flow` variable specific parameters.", 281470681805658], + ["unit__to_node__investment_group", ["unit", "node", "investment_group"], "Indicates which unit capacities are included in the capacity invested available of an investment group"], + ["unit__to_node__user_constraint", ["unit", "node", "user_constraint"], "Defines which output `unit_flows` are included in the `user_constraint`, and holds their parameters."], + ["unit__user_constraint", ["unit", "user_constraint"], "Defines which `units_on` variables are included in the `user_constraint`, and holds their parameters."], + ["units_on__stochastic_structure", ["unit", "stochastic_structure"], "Defines which specific `stochastic_structure` is used for the `units_on` variable of the `unit`. Only one `stochastic_structure` is permitted per `unit`."], + ["units_on__temporal_block", ["unit", "temporal_block"], "Defines which specific `temporal_blocks` are used by the `units_on` variable of the `unit`."] + ], + "parameter_value_lists": [ + ["balance_type_list", "balance_type_group"], + ["balance_type_list", "balance_type_node"], + ["balance_type_list", "balance_type_none"], + ["boolean_value_list", false], + ["boolean_value_list", true], + ["commodity_physics_list", "commodity_physics_lodf"], + ["commodity_physics_list", "commodity_physics_none"], + ["commodity_physics_list", "commodity_physics_ptdf"], + ["connection_investment_variable_type_list", "connection_investment_variable_type_continuous"], + ["connection_investment_variable_type_list", "connection_investment_variable_type_integer"], + ["connection_type_list", "connection_type_lossless_bidirectional"], + ["connection_type_list", "connection_type_normal"], + ["constraint_sense_list", "<="], + ["constraint_sense_list", "=="], + ["constraint_sense_list", ">="], + ["duration_unit_list", "hour"], + ["duration_unit_list", "minute"], + ["model_type_list", "spineopt_benders"], + ["model_type_list", "spineopt_standard"], + ["model_type_list", "spineopt_other"], + ["model_type_list", "spineopt_mga"], + ["node_opf_type_list", "node_opf_type_normal"], + ["node_opf_type_list", "node_opf_type_reference"], + ["unit_investment_variable_type_list", "unit_investment_variable_type_continuous"], + ["unit_investment_variable_type_list", "unit_investment_variable_type_integer"], + ["unit_online_variable_type_list", "unit_online_variable_type_binary"], + ["unit_online_variable_type_list", "unit_online_variable_type_integer"], + ["unit_online_variable_type_list", "unit_online_variable_type_linear"], + ["unit_online_variable_type_list", "unit_online_variable_type_none"], + ["variable_type_list", "variable_type_binary"], + ["variable_type_list", "variable_type_continuous"], + ["variable_type_list", "variable_type_integer"], + ["write_mps_file_list", "write_mps_always"], + ["write_mps_file_list", "write_mps_never"], + ["write_mps_file_list", "write_mps_on_no_solve"], + ["db_mip_solver_list", "KNITRO.jl"], + ["db_mip_solver_list", "Cbc.jl"], + ["db_mip_solver_list", "CPLEX.jl"], + ["db_mip_solver_list", "HiGHS.jl"], + ["db_mip_solver_list", "Xpress.jl"], + ["db_mip_solver_list", "GLPK.jl"], + ["db_mip_solver_list", "Gurobi.jl"], + ["db_mip_solver_list", "Juniper.jl"], + ["db_mip_solver_list", "MosekTools.jl"], + ["db_mip_solver_list", "SCIP.jl"], + ["db_lp_solver_list", "KNITRO.jl"], + ["db_lp_solver_list", "CDCS.jl"], + ["db_lp_solver_list", "CDDLib.jl"], + ["db_lp_solver_list", "Clp.jl"], + ["db_lp_solver_list", "COSMO.jl"], + ["db_lp_solver_list", "CPLEX.jl"], + ["db_lp_solver_list", "CSDP.jl"], + ["db_lp_solver_list", "ECOS.jl"], + ["db_lp_solver_list", "Xpress.jl"], + ["db_lp_solver_list", "GLPK.jl"], + ["db_lp_solver_list", "Gurobi.jl"], + ["db_lp_solver_list", "HiGHS.jl"], + ["db_lp_solver_list", "Hypatia.jl"], + ["db_lp_solver_list", "Ipopt.jl"], + ["db_lp_solver_list", "MadNLP.jl"], + ["db_lp_solver_list", "MosekTools.jl"], + ["db_lp_solver_list", "NLopt.jl"], + ["db_lp_solver_list", "OSQP.jl"], + ["db_lp_solver_list", "ProxSDP.jl"], + ["db_lp_solver_list", "SCIP.jl"], + ["db_lp_solver_list", "SCS.jl"], + ["db_lp_solver_list", "SDPA.jl"], + ["db_lp_solver_list", "SDPNAL.jl"], + ["db_lp_solver_list", "SDPT3.jl"], + ["db_lp_solver_list", "SeDuMi.jl"] + ], + "object_parameters": [ + ["commodity", "commodity_lodf_tolerance", 0.1, null, "The minimum absolute value of the line outage distribution factor (LODF) that is considered meaningful."], + ["commodity", "commodity_physics", "commodity_physics_none", "commodity_physics_list", "Defines if the `commodity` follows lodf or ptdf physics."], + ["commodity", "commodity_physics_duration", null, null, "For how long the `commodity_physics` should apply relative to the start of the window."], + ["commodity", "commodity_ptdf_threshold", 0.001, null, "The minimum absolute value of the power transfer distribution factor (PTDF) that is considered meaningful."], + ["commodity", "mp_min_res_gen_to_demand_ratio", null, null, "Minimum ratio of renewable generation to demand for this commodity - used in the minimum renewable generation constraint within the Benders master problem"], + ["commodity", "mp_min_res_gen_to_demand_ratio_slack_penalty", null, null, "Penalty for violating the minimum renewable generation to demand ratio."], + ["commodity", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["connection", "candidate_connections", null, null, "The number of connections that may be invested in"], + ["connection", "benders_starting_connections_invested", null, null, "Fixes the number of connections invested during the first Benders iteration"], + ["connection", "forced_availability_factor", null, null, "Availability factor due to outages/deratings."], + ["connection", "connection_availability_factor", 1.0, null, "Availability of the `connection`, acting as a multiplier on its `connection_capacity`. Typically between 0-1."], + ["connection", "connection_contingency", null, "boolean_value_list", "A boolean flag for defining a contingency `connection`."], + ["connection", "connection_investment_cost", null, null, "The per unit investment cost for the connection over the `connection_investment_lifetime`"], + ["connection", "connection_investment_lifetime", null, null, "Determines the minimum investment lifetime of a connection. Once invested, it remains in service for this long"], + ["connection", "connection_investment_variable_type", "variable_type_integer", "variable_type_list", "Determines whether the investment variable is integer `variable_type_integer` or continuous `variable_type_continuous`"], + ["connection", "connection_monitored", false, "boolean_value_list", "A boolean flag for defining a contingency `connection`."], + ["connection", "connection_reactance", null, null, "The per unit reactance of a `connection`."], + ["connection", "connection_reactance_base", 1, null, "If the reactance is given for a p.u. (e.g. p.u. = 100MW), the `connection_reactance_base` can be set to perform this conversion (e.g. *100)."], + ["connection", "connection_resistance", null, null, "The per unit resistance of a `connection`."], + ["connection", "connection_type", "connection_type_normal", "connection_type_list", "A selector between a normal and a lossless bidirectional `connection`."], + ["connection", "fix_connections_invested", null, null, "Setting a value fixes the connections_invested variable accordingly"], + ["connection", "fix_connections_invested_available", null, null, "Setting a value fixes the connections_invested_available variable accordingly"], + ["connection", "initial_connections_invested", null, null, "Setting a value fixes the connections_invested variable at the beginning"], + ["connection", "initial_connections_invested_available", null, null, "Setting a value fixes the connections_invested_available variable at the beginning"], + ["connection", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["connection", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["connection", "has_binary_gas_flow", false, "boolean_value_list", "This parameter needs to be set to `true` in order to represent bidirectional pressure drive gas transfer."], + ["connection", "connections_invested_big_m_mga", null, null, "big_m_mga should be chosen as small as possible but sufficiently large. For units_invested_mga an appropriate big_m_mga would be twice the candidate connections."], + ["connection", "connections_invested_mga", false, "boolean_value_list", "Defines whether a certain variable (here: connections_invested) will be considered in the maximal-differences of the mga objective"], + ["connection", "connections_invested_mga_weight", 1, null, "Used to scale mga variables. For weightd sum mga method, the length of this weight given as an Array will determine the number of iterations."], + ["investment_group", "equal_investments", false, "boolean_value_list", "Whether all entities in the group must have the same investment decision."], + ["investment_group", "minimum_entities_invested_available", null, null, "Lower bound on the number of entities invested available in the group at any point in time."], + ["investment_group", "maximum_entities_invested_available", null, null, "Upper bound on the number of entities invested available in the group at any point in time."], + ["investment_group", "minimum_capacity_invested_available", null, null, "Lower bound on the capacity invested available in the group at any point in time."], + ["investment_group", "maximum_capacity_invested_available", null, null, "Upper bound on the capacity invested available in the group at any point in time."], + ["model", "duration_unit", "hour", "duration_unit_list", "Defines the base temporal unit of the `model`. Currently supported values are either an `hour` or a `minute`."], + ["model", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["model", "max_gap", 0.05, null, "Specifies the maximum optimality gap for the model. Currently only used for the master problem within a decomposed structure"], + ["model", "min_iterations", 1.0, null, "Specifies the minimum number of iterations for the model. Currently only used for the master problem within a decomposed structure"], + ["model", "max_iterations", 10.0, null, "Specifies the maximum number of iterations for the model. Currently only used for the master problem within a decomposed structure"], + ["model", "max_mga_iterations", null, null, "Define the number of mga iterations, i.e. how many alternative solutions will be generated."], + ["model", "max_mga_slack", 0.05, null, "Defines the maximum slack by which the alternative solution may differ from the original solution (e.g. 5% more than initial objective function value)"], + ["model", "model_end", {"type": "date_time", "data": "2000-01-02T00:00:00"}, null, "Defines the last timestamp to be modelled. Rolling optimization terminates after passing this point."], + ["model", "model_start", {"type": "date_time", "data": "2000-01-01T00:00:00"}, null, "Defines the first timestamp to be modelled. Relative `temporal_blocks` refer to this value for their start and end."], + ["model", "model_type", "spineopt_standard", "model_type_list", "Used to identify model objects as relating to the master problem or operational sub problems (default)"], + ["model", "roll_forward", null, null, "Defines how much the model moves ahead in time between solves in a rolling optimization. Without this parameter, everything is solved in as a single optimization."], + ["model", "write_lodf_file", false, "boolean_value_list", "A boolean flag for whether the LODF values should be written to a results file."], + ["model", "write_mps_file", null, "write_mps_file_list", "A selector for writing an .mps file of the model."], + ["model", "write_ptdf_file", false, "boolean_value_list", "A boolean flag for whether the LODF values should be written to a results file."], + ["model","big_m", 1000000, null, "Sufficiently large number used for linearization bilinear terms, e.g. to enforce bidirectional flow for gas pipielines"], + ["model", "db_lp_solver", "HiGHS.jl", "db_lp_solver_list", "Solver for MIP problems. Solver package must be added and pre-configured in Julia. Overrides lp_solver RunSpineOpt kwarg"], + ["model", "db_mip_solver", "HiGHS.jl", "db_mip_solver_list", "Solver for MIP problems. Solver package must be added and pre-configured in Julia. Overrides mip_solver RunSpineOpt kwarg"], + ["model", "db_mip_solver_options", {"type": "map", "index_type": "str", "data": [["HiGHS.jl", {"type": "map", "index_type": "str", "data": [["presolve", "on"], ["mip_rel_gap", 0.01], ["threads", 0.0], ["time_limit", 300.01]]}], ["Cbc.jl", {"type": "map", "index_type": "str", "data": [["ratioGap", 0.01], ["logLevel", 0.0]]}], ["CPLEX.jl", {"type": "map", "index_type": "str", "data": [["CPX_PARAM_EPGAP", 0.01]]}]]}, null, "Map parameter containing MIP solver option name option value pairs for MIP. See solver documentation for supported solver options"], + ["model", "db_lp_solver_options", {"type": "map", "index_type": "str", "data": [["HiGHS.jl", {"type": "map", "index_type": "str", "data": [["presolve", "on"], ["time_limit", 300.01]]}], ["Clp.jl", {"type": "map", "index_type": "str", "data": [["LogLevel", 0.0]]}]]}, null, "Map parameter containing LP solver option name option value pairs. See solver documentation for supported solver options"], + ["model", "window_duration", null, null, "The duration of the window in case it differs from roll_forward"], + ["model", "window_weight", 1, null, "The weight of the window in the rolling subproblem"], + ["node", "balance_type", "balance_type_node", "balance_type_list", "A selector for how the `:nodal_balance` constraint should be handled."], + ["node", "candidate_storages", null, null, "Determines the maximum number of new storages which may be invested in"], + ["node", "benders_starting_storages_invested", null, null, "Fixes the number of storages invested during the first Benders iteration"], + ["node", "demand", 0.0, null, "Demand for the `commodity` of a `node`. Energy gains can be represented using negative `demand`."], + ["node", "downward_reserve", false, null, "Identifier for `node`s providing downward reserves"], + ["node", "fix_node_state", null, null, "Fixes the corresponding `node_state` variable to the provided value. Can be used for e.g. fixing boundary conditions."], + ["node", "fix_storages_invested", null, null, "Used to fix the value of the storages_invested variable"], + ["node", "fix_storages_invested_available", null, null, "Used to fix the value of the storages_invested_available variable"], + ["node", "fix_node_pressure", null, null, "Fixes the corresponding `node_pressure` variable to the provided value"], + ["node", "fix_node_voltage_angle", null, null, "Fixes the corresponding `node_voltage_angle` variable to the provided value"], + ["node", "initial_node_state", null, null, "Initializes the corresponding `node_state` variable to the provided value."], + ["node", "initial_storages_invested", null, null, "Used to initialze the value of the storages_invested variable"], + ["node", "initial_storages_invested_available", null, null, "Used to initialze the value of the storages_invested_available variable"], + ["node", "initial_node_pressure", null, null, "Initializes the corresponding `node_pressure` variable to the provided value"], + ["node", "initial_node_voltage_angle", null, null, "Initializes the corresponding `node_voltage_angle` variable to the provided value"], + ["node", "frac_state_loss", 0.0, null, "Self-discharge coefficient for `node_state` variables. Effectively, represents the *loss power per unit of state*."], + ["node", "fractional_demand", 0.0, null, "The fraction of a `node` group's `demand` applied for the `node` in question."], + ["node", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["node", "has_state", false, "boolean_value_list", "A boolean flag for whether a `node` has a `node_state` variable."], + ["node", "has_pressure", false, "boolean_value_list", "A boolean flag for whether a `node` has a `node_pressure` variable."], + ["node", "has_voltage_angle", false, "boolean_value_list", "A boolean flag for whether a `node` has a `node_voltage_angle` variable."], + ["node", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["node", "is_reserve_node", false, "boolean_value_list", "A boolean flag for whether a `node` is acting as a `reserve_node`"], + ["node", "is_non_spinning", false, "boolean_value_list", "A boolean flag for whether a `node` is acting as a non-spinning reserve"], + ["node", "minimum_reserve_activation_time", null, null, "Duration a certain reserve product needs to be online/available"], + ["node", "nodal_balance_sense", "==", "constraint_sense_list", "A selector for `nodal_balance` constraint sense."], + ["node", "node_opf_type", "node_opf_type_normal", "node_opf_type_list", "A selector for the reference `node` (slack bus) when PTDF-based DC load-flow is enabled."], + ["node", "node_slack_penalty", null, null, "A penalty cost for `node_slack_pos` and `node_slack_neg` variables. The slack variables won't be included in the model unless there's a cost defined for them."], + ["node", "node_state_cap", null, null, "The maximum permitted value for a `node_state` variable."], + ["node", "node_state_min", 0.0, null, "The minimum permitted value for a `node_state` variable."], + ["node", "state_coeff", 1.0, null, "Represents the `commodity` content of a `node_state` variable in respect to the `unit_flow` and `connection_flow` variables. Essentially, acts as a coefficient on the `node_state` variable in the `:node_injection` constraint."], + ["node", "storage_investment_cost", null, null, "Determines the investment cost per unit state_cap over the investment life of a storage"], + ["node", "storage_investment_lifetime", null, null, "Minimum lifetime for storage investment decisions."], + ["node", "storage_investment_variable_type", "variable_type_integer", "variable_type_list", "Determines whether the storage investment variable is continuous (usually representing capacity) or integer (representing discrete units invested)"], + ["node", "tax_in_unit_flow", null, null, "Tax costs for incoming `unit_flows` on this `node`. E.g. EUR/MWh."], + ["node", "tax_net_unit_flow", null, null, "Tax costs for net incoming and outgoing `unit_flows` on this `node`. Incoming flows accrue positive net taxes, and outgoing flows accrue negative net taxes."], + ["node", "tax_out_unit_flow", null, null, "Tax costs for outgoing `unit_flows` from this `node`. E.g. EUR/MWh."], + ["node", "upward_reserve", false, null, "Identifier for `node`s providing upward reserves"], + ["node", "max_node_pressure", null, null, "Maximum allowed gas pressure at `node`."], + ["node", "min_node_pressure", null, null, "Minimum allowed gas pressure at `node`."], + ["node", "max_voltage_angle", null, null, "Maximum allowed voltage angle at `node`."], + ["node", "min_voltage_angle", null, null, "Minimum allowed voltage angle at `node`. "], + ["node", "storages_invested_big_m_mga", null, null, "big_m_mga should be chosen as small as possible but sufficiently large. For units_invested_mga an appropriate big_m_mga would be twice the candidate storages."], + ["node", "storages_invested_mga", false, "boolean_value_list", "Defines whether a certain variable (here: storages_invested) will be considered in the maximal-differences of the mga objective"], + ["node", "storages_invested_mga_weight", 1, null, "Used to scale mga variables. For weighted-sum mga method, the length of this weight given as an Array will determine the number of iterations."], + ["output", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["output", "output_resolution", null, null, "Temporal resolution of the output variables associated with this `output`."], + ["report", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["report", "output_db_url", null, null, "Database url for SpineOpt output."], + ["settings", "version", 10, null, "Current version of the SpineOpt data structure. Modify it at your own risk (but please don't)."], + ["stochastic_scenario", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["stochastic_structure", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["temporal_block", "block_end", null, null, "The end time for the `temporal_block`. Can be given either as a `DateTime` for a static end point, or as a `Duration` for an end point relative to the start of the current optimization."], + ["temporal_block", "block_start", null, null, "The start time for the `temporal_block`. Can be given either as a `DateTime` for a static start point, or as a `Duration` for an start point relative to the start of the current optimization."], + ["temporal_block", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["temporal_block", "resolution", {"type": "duration", "data": "1h"}, null, "Temporal resolution of the `temporal_block`. Essentially, divides the period between `block_start` and `block_end` into `TimeSlices` with the input `resolution`."], + ["temporal_block", "weight", 1.0, null, "Weighting factor of the temporal block associated with the objective function"], + ["temporal_block", "representative_periods_mapping", null, null, "Map from date time to representative temporal block name"], + ["unit", "candidate_units", null, null, "Number of units which may be additionally constructed"], + ["unit", "benders_starting_units_invested", null, null, "Fixes the number of units invested during the first Benders iteration"], + ["unit", "curtailment_cost", null, null, "Costs for curtailing generation. Essentially, accrues costs whenever `unit_flow` not operating at its maximum available capacity. E.g. EUR/MWh"], + ["unit", "fix_units_invested", null, null, "Fix the value of the `units_invested` variable."], + ["unit", "fix_units_invested_available", null, null, "Fix the value of the `units_invested_available` variable"], + ["unit", "fix_units_on", null, null, "Fix the value of the `units_on` variable."], + ["unit", "initial_units_invested", null, null, "Initialize the value of the `units_invested` variable."], + ["unit", "initial_units_invested_available", null, null, "Initialize the value of the `units_invested_available` variable"], + ["unit", "initial_units_on", null, null, "Initialize the value of the `units_on` variable."], + ["unit", "fom_cost", null, null, "Fixed operation and maintenance costs of a `unit`. Essentially, a cost coefficient on the existing units (incl. `number_of_units` and `units_invested_available`) and `unit_capacity` parameters. E.g. EUR/MWh"], + ["unit", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["unit", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["unit", "min_down_time", null, null, "Minimum downtime of a `unit` after it shuts down."], + ["unit", "min_up_time", null, null, "Minimum uptime of a `unit` after it starts up."], + ["unit", "number_of_units", 1.0, null, "Denotes the number of 'sub units' aggregated to form the modelled `unit`."], + ["unit", "online_variable_type", "unit_online_variable_type_linear", "unit_online_variable_type_list", "A selector for how the `units_on` variable is represented within the model."], + ["unit", "shut_down_cost", null, null, "Costs of shutting down a 'sub unit', e.g. EUR/shutdown."], + ["unit", "start_up_cost", null, null, "Costs of starting up a 'sub unit', e.g. EUR/startup."], + ["unit", "forced_availability_factor", null, null, "Availability factor due to outages/deratings."], + ["unit", "unit_availability_factor", 1.0, null, "Availability of the `unit`, acting as a multiplier on its `unit_capacity`. Typically between 0-1."], + ["unit", "unit_investment_cost", null, null, "Investment cost per 'sub unit' built."], + ["unit", "unit_investment_lifetime", null, null, "Minimum lifetime for unit investment decisions."], + ["unit", "unit_investment_variable_type", "unit_investment_variable_type_continuous", "unit_investment_variable_type_list", "Determines whether investment variable is integer or continuous."], + ["unit", "units_on_non_anticipativity_time", null, null, "Period of time where the value of the `units_on` variable has to be fixed to the result from the previous window."], + ["unit", "units_on_non_anticipativity_margin", null, null, "Margin by which `units_on` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["unit", "units_on_cost", null, null, "Objective function coefficient on `units_on`. An idling cost, for example"], + ["unit", "units_invested_big_m_mga", null, null, "big_m_mga should be chosen as small as possible but sufficiently large. For units_invested_mga an appropriate big_m_mga would be twice the candidate units."], + ["unit", "units_invested_mga", false, "boolean_value_list", "Defines whether a certain variable (here: units_invested) will be considered in the maximal-differences of the mga objective"], + ["unit", "units_invested_mga_weight", 1, null, "Used to scale mga variables. For weightd sum mga method, the length of this weight given as an Array will determine the number of iterations."], + ["unit", "is_renewable", false, "boolean_value_list", "Whether the unit is renewable - used in the minimum renewable generation constraint within the Benders master problem"], + ["user_constraint", "constraint_sense", "==", "constraint_sense_list", "A selector for the sense of the `user_constraint`."], + ["user_constraint", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["user_constraint", "right_hand_side", 0.0, null, "The right-hand side, constant term in a `user_constraint`. Can be time-dependent and used e.g. for complicated efficiency approximations."], + ["user_constraint", "user_constraint_slack_penalty", null, null, "A penalty for violating a user constraint."] + ], + "relationship_parameters": [ + ["connection__from_node", "connection_capacity", null, null, "Limits the `connection_flow` variable from the `from_node`. `from_node` can be a group of `nodes`, in which case the sum of the `connection_flow` is constrained."], + ["connection__from_node", "connection_conv_cap_to_flow", 1.0, null, "Optional coefficient for `connection_capacity` unit conversions in the case that the `connection_capacity` value is incompatible with the desired `connection_flow` units."], + ["connection__from_node", "connection_emergency_capacity", null, null, "Post contingency flow capacity of a `connection`. Sometimes referred to as emergency rating"], + ["connection__from_node", "fix_connection_flow", null, null, "Fix the value of the `connection_flow` variable."], + ["connection__from_node", "fix_binary_gas_connection_flow", null, null, "Fix the value of the `connection_flow_binary` variable, and hence pre-determine the direction of flow in the connection."], + ["connection__from_node", "fix_connection_intact_flow", null, null, "Fix the value of the `connection_intact_flow` variable."], + ["connection__from_node", "initial_connection_flow", null, null, "Initialize the value of the `connection_flow` variable."], + ["connection__from_node", "initial_binary_gas_connection_flow", null, null, "Initialize the value of the `connection_flow_binary` variable, and hence pre-determine the direction of flow in the connection."], + ["connection__from_node", "initial_connection_intact_flow", null, null, "Initialize the value of the `connection_intact_flow` variable."], + ["connection__from_node", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["connection__from_node", "connection_flow_cost", null, null, "Variable costs of a flow through a `connection`. E.g. EUR/MWh of energy throughput."], + ["connection__from_node", "connection_flow_non_anticipativity_time", null, null, "Period of time where the value of the `connection_flow` variable has to be fixed to the result from the previous window."], + ["connection__from_node", "connection_flow_non_anticipativity_margin", null, null, "Margin by which `connection_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["connection__from_node", "connection_intact_flow_non_anticipativity_time", null, null, "Period of time where the value of the `connection_intact_flow` variable has to be fixed to the result from the previous window."], + ["connection__from_node", "connection_intact_flow_non_anticipativity_margin", null, null, "Margin by which `connection_intact_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["connection__from_node__user_constraint", "connection_flow_coefficient", 0.0, null, "defines the user constraint coefficient on the connection flow variable in the from direction"], + ["connection__node__node", "connection_flow_delay", {"type": "duration", "data": "0h"}, null, "Delays the `connection_flows` associated with the latter `node` in respect to the `connection_flows` associated with the first `node`."], + ["connection__node__node", "fix_ratio_out_in_connection_flow", null, null, "Fix the ratio between an outgoing `connection_flow` to the first `node` and an incoming `connection_flow` from the second `node`."], + ["connection__node__node", "max_ratio_out_in_connection_flow", null, null, "Maximum ratio between an outgoing `connection_flow` to the first `node` and an incoming `connection_flow` from the second `node`."], + ["connection__node__node", "min_ratio_out_in_connection_flow", null, null, "Minimum ratio between an outgoing `connection_flow` to the first `node` and an incoming `connection_flow` from the second `node`."], + ["connection__node__node", "fixed_pressure_constant_1", null, null, "Fixed pressure points for pipelines for the outer approximation of the Weymouth approximation. The direction of flow is the first node in the relationship to the second node in the relationship."], + ["connection__node__node", "fixed_pressure_constant_0", null, null, "Fixed pressure points for pipelines for the outer approximation of the Weymouth approximation. The direction of flow is the first node in the relationship to the second node in the relationship."], + ["connection__node__node", "compression_factor", null, null, "The compression factor establishes a compression from an origin node to a receiving node, which are connected through a connection. The first node corresponds to the origin node, the second to the (compressed) destination node. Typically the value is >=1."], + ["connection__node__node", "connection_linepack_constant", null, null, "The linepack constant is a property of gas pipelines and relates the linepack to the pressure of the adjacent nodes."], + ["connection__to_node", "connection_capacity", null, null, "Limits the `connection_flow` variable to the `to_node`. `to_node` can be a group of `nodes`, in which case the sum of the `connection_flow` is constrained."], + ["connection__to_node", "connection_conv_cap_to_flow", 1.0, null, "Optional coefficient for `connection_capacity` unit conversions in the case the `connection_capacity` value is incompatible with the desired `connection_flow` units."], + ["connection__to_node", "connection_emergency_capacity", null, null, "The maximum post-contingency flow on a monitored `connection`."], + ["connection__to_node", "fix_connection_flow", null, null, "Fix the value of the `connection_flow` variable."], + ["connection__to_node", "fix_binary_gas_connection_flow", null, null, "Fix the value of the `connection_flow_binary` variable, and hence pre-determine the direction of flow in the connection."], + ["connection__to_node", "fix_connection_intact_flow", null, null, "Fix the value of the `connection_intact_flow` variable."], + ["connection__to_node", "initial_connection_flow", null, null, "Initialize the value of the `connection_flow` variable."], + ["connection__to_node", "initial_binary_gas_connection_flow", null, null, "Initialize the value of the `connection_flow_binary` variable, and hence pre-determine the direction of flow in the connection."], + ["connection__to_node", "initial_connection_intact_flow", null, null, "Initialize the value of the `connection_intact_flow` variable."], + ["connection__to_node", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["connection__to_node", "connection_flow_cost", null, null, "Variable costs of a flow through a `connection`. E.g. EUR/MWh of energy throughput."], + ["connection__to_node", "connection_flow_non_anticipativity_time", null, null, "Period of time where the value of the `connection_flow` variable has to be fixed to the result from the previous window."], + ["connection__to_node", "connection_flow_non_anticipativity_margin", null, null, "Margin by which `connection_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["connection__to_node", "connection_intact_flow_non_anticipativity_time", null, null, "Period of time where the value of the `connection_intact_flow` variable has to be fixed to the result from the previous window."], + ["connection__to_node", "connection_intact_flow_non_anticipativity_margin", null, null, "Margin by which `connection_intact_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["connection__to_node__user_constraint", "connection_flow_coefficient", 0.0, null, "defines the user constraint coefficient on the connection flow variable in the to direction"], + ["connection__user_constraint", "connections_invested_coefficient", 0.0, null, "coefficient of `connections_invested` in the specific `user_constraint`"], + ["connection__user_constraint", "connections_invested_available_coefficient", 0.0, null, "coefficient of `connections_invested_available` in the specific `user_constraint`"], + ["node__node", "diff_coeff", 0.0, null, "Commodity diffusion coefficient between two `nodes`. Effectively, denotes the *diffusion power per unit of state* from the first `node` to the second."], + ["node__temporal_block", "cyclic_condition", false, "boolean_value_list", "If the cyclic condition is set to true for a storage node, the `node_state` at the end of the optimization window has to be larger than or equal to the initial storage state."], + ["node__temporal_block", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["node__stochastic_structure", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["node__user_constraint", "demand_coefficient", 0.0, null, "coefficient of the specified node's demand in the specified user constraint"], + ["node__user_constraint", "node_state_coefficient", 0.0, null, "Coefficient of the specified node's state variable in the specified user constraint."], + ["node__user_constraint", "storages_invested_coefficient", 0.0 , null, "Coefficient of the specified node's storage investment variable in the specified user constraint."], + ["node__user_constraint", "storages_invested_available_coefficient", 0.0, null, "Coefficient of the specified node's storages invested available variable in the specified user constraint."], + ["report__output", "overwrite_results_on_rolling", true, null, "Whether or not results from further windows should overwrite results from previous ones."], + ["stochastic_structure__stochastic_scenario", "stochastic_scenario_end", null, null, "A `Duration` for when a `stochastic_scenario` ends and its `child_stochastic_scenarios` start. Values are interpreted relative to the start of the current solve, and if no value is given, the `stochastic_scenario` is assumed to continue indefinitely."], + ["stochastic_structure__stochastic_scenario", "weight_relative_to_parents", 1.0, null, "The weight of the `stochastic_scenario` in the objective function relative to its parents."], + ["unit__commodity", "max_cum_in_unit_flow_bound", null, null, "Set a maximum cumulative upper bound for a `unit_flow`"], + ["unit__from_node", "fix_nonspin_units_started_up", null, null, "Fix the `nonspin_units_started_up` variable."], + ["unit__from_node", "fix_unit_flow", null, null, "Fix the `unit_flow` variable."], + ["unit__from_node", "fix_unit_flow_op", null, null, "Fix the `unit_flow_op` variable."], + ["unit__from_node", "initial_nonspin_units_started_up", null, null, "Initialize the `nonspin_units_started_up` variable."], + ["unit__from_node", "initial_unit_flow", null, null, "Initialize the `unit_flow` variable."], + ["unit__from_node", "initial_unit_flow_op", null, null, "Initialize the `unit_flow_op` variable."], + ["unit__from_node", "min_unit_flow", 0.0, null, "Set lower bound of the `unit_flow` variable."], + ["unit__from_node", "fuel_cost", null, null, "Variable fuel costs than can be attributed to a `unit_flow`. E.g. EUR/MWh"], + ["unit__from_node", "reserve_procurement_cost", null, null, "Procurement cost for reserves"], + ["unit__from_node", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["unit__from_node", "shut_down_limit", null, null, "Max. downward ramp for units shutting down"], + ["unit__from_node", "start_up_limit", null, null, "Maximum ramp-up during startups."], + ["unit__from_node", "max_total_cumulated_unit_flow_from_node", null, null, "Bound on the maximum cumulated flows of a unit group from a node group e.g max consumption of certain commodity."], + ["unit__from_node", "min_total_cumulated_unit_flow_from_node", null, null, "Bound on the minimum cumulated flows of a unit group from a node group."], + ["unit__from_node", "minimum_operating_point", null, null, "Minimum level for the `unit_flow` relative to the `units_on` online capacity."], + ["unit__from_node", "operating_points", null, null, "Operating points for piecewise-linear `unit` efficiency approximations."], + ["unit__from_node", "ramp_down_limit", null, null, "Limit the maximum ramp-down rate of an online unit, given as a fraction of the unit_capacity. [ramp_down_limit] = %/t, e.g. 0.2/h"], + ["unit__from_node", "ramp_up_limit", null, null, "Limit the maximum ramp-up rate of an online unit, given as a fraction of the unit_capacity. [ramp_up_limit] = %/t, e.g. 0.2/h"], + ["unit__from_node", "unit_capacity", null, null, "Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."], + ["unit__from_node", "unit_conv_cap_to_flow", 1.0, null, "Optional coefficient for `unit_capacity` unit conversions in the case the `unit_capacity` value is incompatible with the desired `unit_flow` units."], + ["unit__from_node", "vom_cost", null, null, "Variable operating costs of a `unit_flow` variable. E.g. EUR/MWh."], + ["unit__from_node", "unit_flow_non_anticipativity_time", null, null, "Period of time where the value of the `unit_flow` variable has to be fixed to the result from the previous window."], + ["unit__from_node", "unit_flow_non_anticipativity_margin", null, null, "Margin by which `unit_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["unit__from_node", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["unit__from_node", "ordered_unit_flow_op", false, "boolean_value_list", "Defines whether the segments of this unit flow are ordered as per the rank of their operating points."], + ["unit__from_node__user_constraint", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["unit__from_node__user_constraint", "unit_flow_coefficient", 0.0, null, "Coefficient of a `unit_flow` variable for a custom `user_constraint`."], + ["unit__node__node", "fix_ratio_in_in_unit_flow", null, null, "Fix the ratio between two `unit_flows` coming into the `unit` from the two `nodes`."], + ["unit__node__node", "fix_ratio_in_out_unit_flow", null, null, "Fix the ratio between an incoming `unit_flow` from the first `node` and an outgoing `unit_flow` to the second `node`."], + ["unit__node__node", "fix_ratio_out_in_unit_flow", null, null, "Fix the ratio between an outgoing `unit_flow` to the first `node` and an incoming `unit_flow` from the second `node`."], + ["unit__node__node", "fix_ratio_out_out_unit_flow", null, null, "Fix the ratio between two `unit_flows` going from the `unit` into the two `nodes`."], + ["unit__node__node", "fix_units_on_coefficient_in_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `fix_ratio_in_in_unit_flow` constraint."], + ["unit__node__node", "fix_units_on_coefficient_in_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `fix_ratio_in_out_unit_flow` constraint."], + ["unit__node__node", "fix_units_on_coefficient_out_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `fix_ratio_out_in_unit_flow` constraint."], + ["unit__node__node", "fix_units_on_coefficient_out_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `fix_ratio_out_out_unit_flow` constraint."], + ["unit__node__node", "max_ratio_in_in_unit_flow", null, null, "Maximum ratio between two `unit_flows` coming into the `unit` from the two `nodes`."], + ["unit__node__node", "max_ratio_in_out_unit_flow", null, null, "Maximum ratio between an incoming `unit_flow` from the first `node` and an outgoing `unit_flow` to the second `node`."], + ["unit__node__node", "max_ratio_out_in_unit_flow", null, null, "Maximum ratio between an outgoing `unit_flow` to the first `node` and an incoming `unit_flow` from the second `node`."], + ["unit__node__node", "max_ratio_out_out_unit_flow", null, null, "Maximum ratio between two `unit_flows` going from the `unit` into the two `nodes`."], + ["unit__node__node", "max_units_on_coefficient_in_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `max_ratio_in_in_unit_flow` constraint."], + ["unit__node__node", "max_units_on_coefficient_in_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `max_ratio_in_out_unit_flow` constraint."], + ["unit__node__node", "max_units_on_coefficient_out_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `max_ratio_out_in_unit_flow` constraint."], + ["unit__node__node", "max_units_on_coefficient_out_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `max_ratio_out_out_unit_flow` constraint."], + ["unit__node__node", "min_ratio_in_in_unit_flow", null, null, "Minimum ratio between two `unit_flows` coming into the `unit` from the two `nodes`."], + ["unit__node__node", "min_ratio_in_out_unit_flow", null, null, "Minimum ratio between an incoming `unit_flow` from the first `node` and an outgoing `unit_flow` to the second `node`."], + ["unit__node__node", "min_ratio_out_in_unit_flow", null, null, "Minimum ratio between an outgoing `unit_flow` to the first `node` and an incoming `unit_flow` from the second `node`."], + ["unit__node__node", "min_ratio_out_out_unit_flow", null, null, "Minimum ratio between two `unit_flows` going from the `unit` into the two `nodes`."], + ["unit__node__node", "min_units_on_coefficient_in_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `min_ratio_in_in_unit_flow` constraint."], + ["unit__node__node", "min_units_on_coefficient_in_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `min_ratio_in_out_unit_flow` constraint."], + ["unit__node__node", "min_units_on_coefficient_out_in", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `min_ratio_out_in_unit_flow` constraint."], + ["unit__node__node", "min_units_on_coefficient_out_out", 0.0, null, "Optional coefficient for the `units_on` variable impacting the `min_ratio_out_out_unit_flow` constraint."], + ["unit__node__node", "unit_incremental_heat_rate", null, null, "Standard piecewise incremental heat rate where node1 is assumed to be the fuel and node2 is assumed to be electriciy. Assumed monotonically increasing. Array type or single coefficient where the number of coefficients must match the dimensions of `unit_operating_points`"], + ["unit__node__node", "unit_idle_heat_rate", 0.0, null, "Flow from node1 per unit time and per `units_on` that results in no additional flow to node2"], + ["unit__node__node", "unit_start_flow", 0.0, null, "Flow from node1 that is incurred when a unit is started up."], + ["unit__to_node", "fix_nonspin_units_shut_down", null, null, "Fix the `nonspin_units_shut_down` variable."], + ["unit__to_node", "fix_nonspin_units_started_up", null, null, "Fix the `nonspin_units_started_up` variable."], + ["unit__to_node", "fix_unit_flow", null, null, "Fix the `unit_flow` variable."], + ["unit__to_node", "fix_unit_flow_op", null, null, "Fix the `unit_flow_op` variable."], + ["unit__to_node", "initial_nonspin_units_shut_down", null, null, "Initialize the `nonspin_units_shut_down` variable."], + ["unit__to_node", "initial_nonspin_units_started_up", null, null, "Initialize the `nonspin_units_started_up` variable."], + ["unit__to_node", "initial_unit_flow", null, null, "Initialize the `unit_flow` variable."], + ["unit__to_node", "initial_unit_flow_op", null, null, "Initialize the `unit_flow_op` variable."], + ["unit__to_node", "min_unit_flow", 0.0, null, "Set lower bound of the `unit_flow` variable."], + ["unit__to_node", "fuel_cost", null, null, "Variable fuel costs than can be attributed to a `unit_flow`. E.g. EUR/MWh"], + ["unit__to_node", "reserve_procurement_cost", null, null, "Procurement cost for reserves"], + ["unit__to_node", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["unit__to_node", "shut_down_limit", null, null, "Maximum ramp-down during shutdowns"], + ["unit__to_node", "start_up_limit", null, null, "Maximum ramp-up during startups"], + ["unit__to_node", "max_total_cumulated_unit_flow_to_node", null, null, "Bound on the maximum cumulated flows of a unit group to a node group, e.g. total GHG emissions."], + ["unit__to_node", "min_total_cumulated_unit_flow_to_node", null, null, "Bound on the minimum cumulated flows of a unit group to a node group, e.g. total renewable production."], + ["unit__to_node", "minimum_operating_point", null, null, "Minimum level for the `unit_flow` relative to the `units_on` online capacity."], + ["unit__to_node", "operating_points", null, null, "Decomposes the flow variable into a number of separate operating segment variables. Used to in conjunction with `unit_incremental_heat_rate` and/or `user_constraint`s"], + ["unit__to_node", "ramp_down_limit", null, null, "Limit the maximum ramp-down rate of an online unit, given as a fraction of the unit_capacity. [ramp_down_limit] = %/t, e.g. 0.2/h"], + ["unit__to_node", "ramp_up_limit", null, null, "Limit the maximum ramp-up rate of an online unit, given as a fraction of the unit_capacity. [ramp_up_limit] = %/t, e.g. 0.2/h"], + ["unit__to_node", "unit_capacity", null, null, "Maximum `unit_flow` capacity of a single 'sub_unit' of the `unit`."], + ["unit__to_node", "unit_conv_cap_to_flow", 1.0, null, "Optional coefficient for `unit_capacity` unit conversions in the case the `unit_capacity` value is incompatible with the desired `unit_flow` units."], + ["unit__to_node", "vom_cost", null, null, "Variable operating costs of a `unit_flow` variable. E.g. EUR/MWh."], + ["unit__to_node", "unit_flow_non_anticipativity_time", null, null, "Period of time where the value of the `unit_flow` variable has to be fixed to the result from the previous window."], + ["unit__to_node", "unit_flow_non_anticipativity_margin", null, null, "Margin by which `unit_flow` variable can differ from the value in the previous window during `non_anticipativity_time`."], + ["unit__to_node", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["unit__to_node", "ordered_unit_flow_op", false, "boolean_value_list", "Defines whether the segments of this unit flow are ordered as per the rank of their operating points."], + ["unit__to_node__user_constraint", "graph_view_position", null, null, "An optional setting for tweaking the position of the different elements when drawing them via Spine Toolbox Graph View."], + ["unit__to_node__user_constraint", "unit_flow_coefficient", 0.0, null, "Coefficient of a `unit_flow` variable for a custom `user_constraint`."], + ["unit__user_constraint", "units_on_coefficient", 0.0, null, "Coefficient of a `units_on` variable for a custom `user_constraint`."], + ["unit__user_constraint", "units_started_up_coefficient", 0.0, null, "Coefficient of a `units_started_up` variable for a custom `user_constraint`."], + ["unit__user_constraint", "units_invested_coefficient", 0.0, null, "Coefficient of the `units_invested` variable in the specified `user_constraint`."], + ["unit__user_constraint", "units_invested_available_coefficient",0.0 , null, "Coefficient of the `units_invested_available` variable in the specified `user_constraint`."], + ["units_on__stochastic_structure", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"], + ["units_on__temporal_block", "is_active", true, "boolean_value_list", "If false, the object is excluded from the model if the tool filter object activity control is specified"] + ], + "objects": [ + ["output", "binary_gas_connection_flow", null], + ["output", "connection_flow", null], + ["output", "connection_intact_flow", null], + ["output", "connections_decommissioned", null], + ["output", "connections_invested_available", null], + ["output", "connections_invested", null], + ["output", "contingency_is_binding", null], + ["output", "mp_objective_lowerbound", null], + ["output", "mga_objective", null], + ["output", "node_injection", null], + ["output", "node_pressure", null], + ["output", "node_slack_neg", null], + ["output", "node_slack_pos", null], + ["output", "node_state", null], + ["output", "node_voltage_angle", null], + ["output", "nonspin_units_shut_down", null], + ["output", "nonspin_units_started_up", null], + ["output", "storages_decommissioned", null], + ["output", "storages_invested_available", null], + ["output", "storages_invested", null], + ["output", "unit_flow_op_active", null], + ["output", "unit_flow_op", null], + ["output", "unit_flow", null], + ["output", "units_available", null], + ["output", "units_invested_available", null], + ["output", "units_invested", null], + ["output", "units_mothballed", null], + ["output", "units_on", null], + ["output", "units_shut_down", null], + ["output", "units_started_up", null], + ["output", "connection_avg_throughflow", null], + ["output", "connection_avg_intact_throughflow", null], + ["output", "variable_om_costs", null], + ["output", "fixed_om_costs", null], + ["output", "taxes", null], + ["output", "fuel_costs", null], + ["output", "unit_investment_costs", null], + ["output", "connection_investment_costs", null], + ["output", "storage_investment_costs", null], + ["output", "start_up_costs", null], + ["output", "shut_down_costs", null], + ["output", "objective_penalties", null], + ["output", "connection_flow_costs", null], + ["output", "renewable_curtailment_costs", null], + ["output", "res_proc_costs", null], + ["output", "units_on_costs", null], + ["output", "total_costs", null], + ["output", "relative_optimality_gap", null] + ], + "tools": [ + ["object_activity_control", ""] + ], + "features": [ + ["commodity", "is_active", "boolean_value_list", "boolean_value_list"], + ["connection", "is_active", "boolean_value_list", "boolean_value_list"], + ["model", "is_active", "boolean_value_list", "boolean_value_list"], + ["node", "is_active", "boolean_value_list", "boolean_value_list"], + ["output", "is_active", "boolean_value_list", "boolean_value_list"], + ["report", "is_active", "boolean_value_list", "boolean_value_list"], + ["stochastic_scenario", "is_active", "boolean_value_list", "boolean_value_list"], + ["stochastic_structure", "is_active", "boolean_value_list", "boolean_value_list"], + ["temporal_block", "is_active", "boolean_value_list", "boolean_value_list"], + ["unit", "is_active", "boolean_value_list", "boolean_value_list"], + ["user_constraint", "is_active", "boolean_value_list", "boolean_value_list"], + ["node__stochastic_structure", "is_active", "boolean_value_list", "boolean_value_list"], + ["node__temporal_block", "is_active", "boolean_value_list", "boolean_value_list"], + ["unit__from_node", "is_active", "boolean_value_list", "boolean_value_list"], + ["unit__to_node", "is_active", "boolean_value_list", "boolean_value_list"], + ["units_on__stochastic_structure", "is_active", "boolean_value_list", "boolean_value_list"], + ["units_on__temporal_block", "is_active", "boolean_value_list", "boolean_value_list"] + ], + "tool_features": [ + ["object_activity_control", "commodity", "is_active", false], + ["object_activity_control", "connection", "is_active", false], + ["object_activity_control", "model", "is_active", false], + ["object_activity_control", "node", "is_active", false], + ["object_activity_control", "output", "is_active", false], + ["object_activity_control", "report", "is_active", false], + ["object_activity_control", "stochastic_scenario", "is_active", false], + ["object_activity_control", "stochastic_structure", "is_active", false], + ["object_activity_control", "temporal_block", "is_active", false], + ["object_activity_control", "unit", "is_active", false], + ["object_activity_control", "user_constraint", "is_active", false], + ["object_activity_control", "node__stochastic_structure", "is_active", false], + ["object_activity_control", "node__temporal_block", "is_active", false], + ["object_activity_control", "unit__to_node", "is_active", false], + ["object_activity_control", "unit__from_node", "is_active", false], + ["object_activity_control", "units_on__stochastic_structure", "is_active", false], + ["object_activity_control", "units_on__temporal_block", "is_active", false] + ], + "tool_feature_methods": [ + ["object_activity_control", "commodity", "is_active", true], + ["object_activity_control", "connection", "is_active", true], + ["object_activity_control", "model", "is_active", true], + ["object_activity_control", "node", "is_active", true], + ["object_activity_control", "output", "is_active", true], + ["object_activity_control", "report", "is_active", true], + ["object_activity_control", "stochastic_scenario", "is_active", true], + ["object_activity_control", "stochastic_structure", "is_active", true], + ["object_activity_control", "temporal_block", "is_active", true], + ["object_activity_control", "unit", "is_active", true], + ["object_activity_control", "user_constraint", "is_active", true], + ["object_activity_control", "node__stochastic_structure", "is_active", true], + ["object_activity_control", "node__temporal_block", "is_active", true], + ["object_activity_control", "unit__to_node", "is_active", true], + ["object_activity_control", "unit__from_node", "is_active", true], + ["object_activity_control", "units_on__stochastic_structure", "is_active", true], + ["object_activity_control", "units_on__temporal_block", "is_active", true] + ] +} diff --git a/tests/test_spine_db_fetcher.py b/tests/test_spine_db_fetcher.py index ad155d97a..fe0975704 100644 --- a/tests/test_spine_db_fetcher.py +++ b/tests/test_spine_db_fetcher.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,15 +10,14 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ``spine_db_fetcher`` module. -""" +"""Unit tests for ``spine_db_fetcher`` module.""" import unittest from unittest.mock import MagicMock from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication from spinetoolbox.fetch_parent import ItemTypeFetchParent from spinedb_api import DatabaseMapping +from spinedb_api.temp_id import TempId from spinedb_api.import_functions import import_data from tests.mock_helpers import TestSpineDBManager @@ -38,20 +38,22 @@ def setUp(self): app_settings = MagicMock() self._logger = MagicMock() # Collects error messages therefore handy for debugging. self._db_mngr = TestSpineDBManager(app_settings, None) - self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="test_db", create=True) + self._db_map = self._db_mngr.get_db_map("sqlite://", self._logger, codename="db_fetcher_test_db", create=True) def tearDown(self): self._db_mngr.close_all_sessions() self._db_mngr.clean_up() def test_fetch_empty_database(self): - self._db_map.remove_items(alternative={1}) - self._db_map.commit_session("ddd") - for item_type in DatabaseMapping.ITEM_TYPES: + for item_type in DatabaseMapping.item_types(): fetcher = TestItemTypeFetchParent(item_type) - if self._db_mngr.can_fetch_more(self._db_map, fetcher): + while self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_not_called() + qApp.processEvents() + if item_type in ("alternative", "commit"): + fetcher.handle_items_added.assert_called_once() + else: + fetcher.handle_items_added.assert_not_called() fetcher.set_obsolete(True) def _import_data(self, **data): @@ -64,20 +66,44 @@ def test_fetch_alternatives(self): if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call( - {self._db_map: [{'id': 1, 'name': 'Base', 'description': 'Base alternative', 'commit_id': 1}]} + { + self._db_map: [ + { + "id": self._db_map.get_alternative_item(id=1)["id"], + "name": "Base", + "description": "Base alternative", + "commit_id": 1, + } + ] + } ) fetcher.handle_items_added.assert_any_call( - {self._db_map: [{'id': 2, 'name': 'alt', 'description': None, 'commit_id': 2}]} + { + self._db_map: [ + { + "id": self._db_map.get_alternative_item(id=2)["id"], + "name": "alt", + "description": None, + "commit_id": 2, + } + ] + } ) self.assertEqual( self._db_mngr.get_item(self._db_map, "alternative", 2), - {'commit_id': 2, 'description': None, 'id': 2, 'name': 'alt'}, + {"commit_id": 2, "description": None, "id": self._db_map.get_alternative_item(id=2)["id"], "name": "alt"}, ) fetcher.set_obsolete(True) def test_fetch_scenarios(self): self._import_data(scenarios=("scenario",)) - item = {'id': 1, 'name': 'scenario', 'description': None, 'active': False, 'commit_id': 2} + item = { + "id": self._db_map.get_scenario_item(id=1)["id"], + "name": "scenario", + "description": None, + "active": False, + "commit_id": 2, + } fetcher = TestItemTypeFetchParent("scenario") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) @@ -87,7 +113,13 @@ def test_fetch_scenarios(self): def test_fetch_scenario_alternatives(self): self._import_data(alternatives=("alt",), scenarios=("scenario",), scenario_alternatives=(("scenario", "alt"),)) - item = {'id': 1, 'scenario_id': 1, 'alternative_id': 2, 'rank': 1, 'commit_id': 2} + item = { + "id": self._db_map.get_scenario_alternative_item(id=1)["id"], + "scenario_id": self._db_map.get_scenario_item(id=1)["id"], + "alternative_id": self._db_map.get_alternative_item(id=2)["id"], + "rank": 1, + "commit_id": 2, + } for item_type in ("scenario", "alternative"): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) @@ -100,95 +132,101 @@ def test_fetch_scenario_alternatives(self): fetcher.set_obsolete(True) def test_fetch_object_classes(self): - self._import_data(object_classes=("oc",)) + self._import_data(entity_classes=(("oc",),)) item = { - 'id': 1, - 'name': 'oc', - 'description': None, - 'display_order': 99, - 'display_icon': None, - 'hidden': 0, - 'commit_id': 2, + "id": self._db_map.get_entity_class_item(id=1)["id"], + "name": "oc", + "description": None, + "display_order": 99, + "display_icon": None, + "hidden": 0, + "active_by_default": False, + "dimension_id_list": (), } - fetcher = TestItemTypeFetchParent("object_class") + fetcher = TestItemTypeFetchParent("entity_class") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertIsInstance(self._db_mngr.entity_class_icon(self._db_map, "object_class", 1), QIcon) - self.assertEqual(self._db_mngr.get_item(self._db_map, "object_class", 1), item) + self.assertIsInstance(self._db_mngr.entity_class_icon(self._db_map, 1), QIcon) + self.assertEqual(self._db_mngr.get_item(self._db_map, "entity_class", 1), item) fetcher.set_obsolete(True) def test_fetch_objects(self): - self._import_data(object_classes=("oc",), objects=(("oc", "obj"),)) - item = {'id': 1, 'class_id': 1, 'name': 'obj', 'description': None, 'commit_id': 2} - self._db_mngr.fetch_more(self._db_map, TestItemTypeFetchParent("object_class")) - for item_type in ("object",): + self._import_data(entity_classes=(("oc",),), entities=(("oc", "obj"),)) + item = { + "id": self._db_map.get_entity_item(id=1)["id"], + "class_id": self._db_map.get_entity_class_item(id=1)["id"], + "name": "obj", + "element_id_list": (), + "description": None, + "commit_id": 2, + } + self._db_mngr.fetch_more(self._db_map, TestItemTypeFetchParent("entity_class")) + for item_type in ("entity",): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("object") + fetcher = TestItemTypeFetchParent("entity") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "object", 1), item) + self.assertEqual(self._db_mngr.get_item(self._db_map, "entity", 1), item) fetcher.set_obsolete(True) def test_fetch_relationship_classes(self): self._import_data(object_classes=("oc",), relationship_classes=(("rc", ("oc",)),)) item = { - 'id': 2, - 'name': 'rc', - 'description': None, - 'object_class_id_list': (1,), - 'object_class_name_list': 'oc', - 'display_icon': None, - 'commit_id': 2, + "id": self._db_map.get_entity_class_item(id=2)["id"], + "name": "rc", + "description": None, + "display_order": 99, + "display_icon": None, + "hidden": 0, + "active_by_default": True, + "dimension_id_list": (self._db_map.get_entity_class_item(id=1)["id"],), } - for item_type in ("object_class",): + for item_type in ("entity_class",): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("relationship_class") + fetcher = TestItemTypeFetchParent("entity_class") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "relationship_class", 2), item) + self.assertEqual(self._db_mngr.get_item(self._db_map, "entity_class", 2), item) fetcher.set_obsolete(True) def test_fetch_relationships(self): - self._import_data( - object_classes=("oc",), - objects=(("oc", "obj"),), - relationship_classes=(("rc", ("oc",)),), - relationships=(("rc", ("obj",)),), - ) + self._import_data(entity_classes=(("oc",), ("rc", ("oc",))), entities=(("oc", "obj"), ("rc", ("obj",)))) item = { - 'id': 2, - 'name': 'rc_obj', - 'class_id': 2, - 'class_name': 'rc', - 'object_id_list': (1,), - 'object_name_list': 'obj', - 'object_class_id_list': (1,), - 'object_class_name_list': 'oc', - 'commit_id': 2, + "id": self._db_map.get_entity_item(id=2)["id"], + "name": "obj__", + "class_id": self._db_map.get_entity_class_item(id=2)["id"], + "element_id_list": (self._db_map.get_entity_item(id=1)["id"],), + "description": None, + "commit_id": 2, } - for item_type in ("object_class", "object", "relationship_class"): + for item_type in ("entity_class", "entity"): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("relationship") + fetcher = TestItemTypeFetchParent("entity") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "relationship", 2), item) + self.assertEqual(self._db_mngr.get_item(self._db_map, "entity", 2), item) fetcher.set_obsolete(True) def test_fetch_object_groups(self): self._import_data( object_classes=("oc",), objects=(("oc", "obj"), ("oc", "group")), object_groups=(("oc", "group", "obj"),) ) - item = {'id': 1, 'entity_class_id': 1, 'entity_id': 2, 'member_id': 1} + item = { + "id": self._db_map.get_entity_group_item(id=1)["id"], + "entity_class_id": self._db_map.get_entity_class_item(id=1)["id"], + "entity_id": self._db_map.get_entity_item(id=2)["id"], + "member_id": self._db_map.get_entity_item(id=1)["id"], + } fetcher = TestItemTypeFetchParent("entity_group") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) @@ -199,26 +237,24 @@ def test_fetch_object_groups(self): def test_fetch_parameter_definitions(self): self._import_data(object_classes=("oc",), object_parameters=(("oc", "param"),)) item = { - 'id': 1, - 'entity_class_id': 1, - 'object_class_id': 1, - 'relationship_class_id': None, - 'name': 'param', - 'parameter_value_list_id': None, - 'default_value': None, - 'default_type': None, - 'list_value_id': None, - 'description': None, - 'commit_id': 2, + "id": self._db_map.get_parameter_definition_item(id=1)["id"], + "entity_class_id": self._db_map.get_entity_class_item(id=1)["id"], + "name": "param", + "parameter_value_list_id": None, + "default_value": None, + "default_type": None, + "description": None, + "commit_id": 2, + "list_value_id": None, } - for item_type in ("object_class",): + for item_type in ("entity_class",): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) dep_fetcher.set_obsolete(True) fetcher = TestItemTypeFetchParent("parameter_definition") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) + fetcher.handle_items_added.assert_called_once_with({self._db_map: [item]}) self.assertEqual(self._db_mngr.get_item(self._db_map, "parameter_definition", 1), item) fetcher.set_obsolete(True) @@ -230,131 +266,48 @@ def test_fetch_parameter_values(self): object_parameter_values=(("oc", "obj", "param", 2.3),), ) item = { - 'id': 1, - 'entity_class_id': 1, - 'object_class_id': 1, - 'relationship_class_id': None, - 'entity_id': 1, - 'object_id': 1, - 'relationship_id': None, - 'parameter_definition_id': 1, - 'alternative_id': 1, - 'value': b'2.3', - 'type': None, - 'list_value_id': None, - 'commit_id': 2, + "id": self._db_map.get_parameter_value_item(id=1)["id"], + "entity_class_id": self._db_map.get_entity_class_item(id=1)["id"], + "entity_id": self._db_map.get_entity_item(id=1)["id"], + "parameter_definition_id": self._db_map.get_parameter_definition_item(id=1)["id"], + "alternative_id": self._db_map.get_alternative_item(id=1)["id"], + "value": b"2.3", + "type": None, + "commit_id": 2, + "list_value_id": None, } - for item_type in ("object_class", "object", "parameter_definition", "alternative"): + for item_type in ("entity_class", "entity", "parameter_definition", "alternative"): dep_fetcher = TestItemTypeFetchParent(item_type) self._db_mngr.fetch_more(self._db_map, dep_fetcher) dep_fetcher.set_obsolete(True) fetcher = TestItemTypeFetchParent("parameter_value") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) + fetcher.handle_items_added.assert_called_once_with({self._db_map: [item]}) self.assertEqual(self._db_mngr.get_item(self._db_map, "parameter_value", 1), item) fetcher.set_obsolete(True) def test_fetch_parameter_value_lists(self): - self._import_data(parameter_value_lists=(("value_list", (2.3,)),)) - item = {'id': 1, 'name': 'value_list', 'commit_id': 2} + self._import_data(parameter_value_lists=(("value_list", [2.3]),)) + item = {"id": self._db_map.get_parameter_value_list_item(id=1)["id"], "name": "value_list", "commit_id": 2} fetcher = TestItemTypeFetchParent("parameter_value_list") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) self.assertEqual(self._db_mngr.get_item(self._db_map, "parameter_value_list", 1), item) - item = {'id': 1, 'parameter_value_list_id': 1, 'index': 0, 'value': b'[2.3]', 'type': None, 'commit_id': 2} - fetcher = TestItemTypeFetchParent("list_value") - if self._db_mngr.can_fetch_more(self._db_map, fetcher): - self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "list_value", 1), item) - fetcher.set_obsolete(True) - - def test_fetch_features(self): - self._import_data( - object_classes=("oc",), - parameter_value_lists=(("value_list", 2.3),), - object_parameters=(("oc", "param", 2.3, "value_list"),), - features=(("oc", "param"),), - ) item = { - 'id': 1, - 'parameter_definition_id': 1, - 'parameter_value_list_id': 1, - 'description': None, - 'commit_id': 2, + "id": self._db_map.get_list_value_item(id=1)["id"], + "parameter_value_list_id": self._db_map.get_parameter_value_list_item(id=1)["id"], + "index": 0, + "value": b"[2.3]", + "type": None, + "commit_id": 2, } - for item_type in ("object_class", "parameter_definition", "parameter_value_list"): - dep_fetcher = TestItemTypeFetchParent(item_type) - self._db_mngr.fetch_more(self._db_map, dep_fetcher) - dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("feature") - if self._db_mngr.can_fetch_more(self._db_map, fetcher): - self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "feature", 1), item) - fetcher.set_obsolete(True) - - def test_fetch_tools(self): - self._import_data(tools=("tool",)) - item = {'id': 1, 'name': 'tool', 'description': None, 'commit_id': 2} - fetcher = TestItemTypeFetchParent("tool") - if self._db_mngr.can_fetch_more(self._db_map, fetcher): - self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "tool", 1), item) - fetcher.set_obsolete(True) - - def test_fetch_tool_features(self): - self._import_data( - object_classes=("oc",), - parameter_value_lists=(("value_list", 2.3),), - object_parameters=(("oc", "param", 2.3, "value_list"),), - features=(("oc", "param"),), - tools=("tool",), - tool_features=(("tool", "oc", "param"),), - ) - item = {'id': 1, 'tool_id': 1, 'feature_id': 1, 'parameter_value_list_id': 1, 'required': False, 'commit_id': 2} - for item_type in ("tool", "feature", "object_class", "parameter_definition", "parameter_value_list"): - dep_fetcher = TestItemTypeFetchParent(item_type) - self._db_mngr.fetch_more(self._db_map, dep_fetcher) - dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("tool_feature") - if self._db_mngr.can_fetch_more(self._db_map, fetcher): - self._db_mngr.fetch_more(self._db_map, fetcher) - fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "tool_feature", 1), item) - fetcher.set_obsolete(True) - - def test_fetch_tool_feature_methods(self): - self._import_data( - object_classes=("oc",), - parameter_value_lists=(("value_list", "m"),), - object_parameters=(("oc", "param", "m", "value_list"),), - features=(("oc", "param"),), - tools=("tool",), - tool_features=(("tool", "oc", "param"),), - tool_feature_methods=(("tool", "oc", "param", "m"),), - ) - item = {'id': 1, 'tool_feature_id': 1, 'parameter_value_list_id': 1, 'method_index': 0, 'commit_id': 2} - for item_type in ( - "object_class", - "parameter_definition", - "parameter_value_list", - "list_value", - "tool", - "feature", - "tool_feature", - ): - dep_fetcher = TestItemTypeFetchParent(item_type) - self._db_mngr.fetch_more(self._db_map, dep_fetcher) - dep_fetcher.set_obsolete(True) - fetcher = TestItemTypeFetchParent("tool_feature_method") + fetcher = TestItemTypeFetchParent("list_value") if self._db_mngr.can_fetch_more(self._db_map, fetcher): self._db_mngr.fetch_more(self._db_map, fetcher) fetcher.handle_items_added.assert_any_call({self._db_map: [item]}) - self.assertEqual(self._db_mngr.get_item(self._db_map, "tool_feature_method", 1), item) + self.assertEqual(self._db_mngr.get_item(self._db_map, "list_value", 1), item) fetcher.set_obsolete(True) diff --git a/tests/test_spine_engine_worker.py b/tests/test_spine_engine_worker.py index 2cfa2fa85..ec4f57d5f 100644 --- a/tests/test_spine_engine_worker.py +++ b/tests/test_spine_engine_worker.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ``spine_engine_worker`` module. -""" +"""Unit tests for ``spine_engine_worker`` module.""" import time import unittest from unittest.mock import MagicMock @@ -69,5 +68,5 @@ def _mark_worker_finished(self): self.finished = True -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/__init__.py b/tests/widgets/__init__.py index 0ffbf46b0..ed79cf443 100644 --- a/tests/widgets/__init__.py +++ b/tests/widgets/__init__.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,6 +10,4 @@ # this program. If not, see . ###################################################################################################################### -""" -Init file for tests.widgets package. Intentionally empty. -""" +"""Init file for tests.widgets package. Intentionally empty.""" diff --git a/tests/widgets/test_AboutWidget.py b/tests/widgets/test_AboutWidget.py index 317ceff42..ebbf1a392 100644 --- a/tests/widgets/test_AboutWidget.py +++ b/tests/widgets/test_AboutWidget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the AboutWidget class. -""" - +"""Unit tests for the AboutWidget class.""" import unittest from PySide6.QtWidgets import QApplication, QWidget from spinetoolbox.widgets.about_widget import AboutWidget @@ -44,5 +42,5 @@ def test_copy_to_clipboard(self): w.close() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_AddProjectItemWidget.py b/tests/widgets/test_AddProjectItemWidget.py index d851c0f85..ab1e03ecd 100644 --- a/tests/widgets/test_AddProjectItemWidget.py +++ b/tests/widgets/test_AddProjectItemWidget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for AddProjectItemWidget. -""" +"""Unit tests for AddProjectItemWidget.""" from tempfile import TemporaryDirectory import unittest from unittest.mock import MagicMock, patch @@ -36,10 +35,7 @@ def setUp(self): "spinetoolbox.ui_main.load_project_items" ) as mock_load_project_items: mock_jump_props_widget.return_value = QWidget() - mock_load_project_items.return_value = ( - {TestProjectItem.item_type(): TestProjectItem.item_category()}, - {TestProjectItem.item_type(): TestItemFactory}, - ) + mock_load_project_items.return_value = {TestProjectItem.item_type(): TestItemFactory} self._toolbox = create_toolboxui_with_project(self._temp_dir.name) def tearDown(self): @@ -71,10 +67,7 @@ def setUp(self): "spinetoolbox.ui_main.load_item_specification_factories" ) as mock_load_specification_factories: mock_jump_props_widget.return_value = QWidget() - mock_load_project_items.return_value = ( - {TestProjectItem.item_type(): TestProjectItem.item_category()}, - {TestProjectItem.item_type(): TestItemFactory}, - ) + mock_load_project_items.return_value = {TestProjectItem.item_type(): TestItemFactory} mock_load_specification_factories.return_value = {TestProjectItem.item_type(): TestSpecificationFactory} self._toolbox = create_toolboxui_with_project(self._temp_dir.name) @@ -96,10 +89,6 @@ def __init__(self, project): def item_type(): return "TestItemType" - @staticmethod - def item_category(): - return "TestCategory" - @property def executable_class(self): raise NotImplementedError() diff --git a/tests/widgets/test_AddUpSpineOptWizard.py b/tests/widgets/test_AddUpSpineOptWizard.py index b96f0c334..e7001a1eb 100644 --- a/tests/widgets/test_AddUpSpineOptWizard.py +++ b/tests/widgets/test_AddUpSpineOptWizard.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the Add/Update SpineOpt Wizard. -""" - +"""Unit tests for the Add/Update SpineOpt Wizard.""" import unittest from unittest import mock from PySide6.QtWidgets import QApplication, QWizard diff --git a/tests/widgets/test_ArrayTableView.py b/tests/widgets/test_ArrayTableView.py index bc795072d..1cecb3497 100644 --- a/tests/widgets/test_ArrayTableView.py +++ b/tests/widgets/test_ArrayTableView.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for ArrayTableView class. -""" - +"""Unit tests for ArrayTableView class.""" import csv import locale from io import StringIO @@ -67,7 +65,7 @@ def test_copy_single_numeric_cell(self): table_view.selectionModel().select(index, QItemSelectionModel.Select) self.assertTrue(table_view.copy()) clip = StringIO(QApplication.clipboard().text()) - array = [row for row in csv.reader(clip, delimiter='\t')] + array = [row for row in csv.reader(clip, delimiter="\t")] with system_lc_numeric(): self.assertEqual(array, [[locale.str(5.5)]]) table_view.deleteLater() @@ -82,7 +80,7 @@ def test_copy_does_not_copy_expansion_row(self): table_view.selectionModel().select(model.index(row, column), QItemSelectionModel.Select) self.assertTrue(table_view.copy()) clip = StringIO(QApplication.clipboard().text()) - array = [row for row in csv.reader(clip, delimiter='\t')] + array = [row for row in csv.reader(clip, delimiter="\t")] with system_lc_numeric(): self.assertEqual(array, [["0", locale.str(5.5)]]) table_view.deleteLater() diff --git a/tests/widgets/test_CopyPasteTableView.py b/tests/widgets/test_CopyPasteTableView.py index 1fa476967..ae443fa86 100644 --- a/tests/widgets/test_CopyPasteTableView.py +++ b/tests/widgets/test_CopyPasteTableView.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,14 +10,11 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for CopyPasteTableView class. -""" - +"""Unit tests for CopyPasteTableView class.""" import locale import unittest -from unittest.mock import patch -from PySide6.QtCore import QAbstractTableModel, QModelIndex, QItemSelectionModel, Qt +from unittest.mock import MagicMock, patch +from PySide6.QtCore import QAbstractTableModel, QItemSelection, QModelIndex, QItemSelectionModel, Qt from PySide6.QtWidgets import QApplication from spinetoolbox.widgets.custom_qtableview import CopyPasteTableView @@ -24,7 +22,7 @@ class _MockModel(QAbstractTableModel): def __init__(self): super().__init__() - self._data = [['a', 'b', '1.1'], ['c', 'd', '2.2'], ['e', 'f', '3.3']] + self._data = [["a", "b", "1.1"], ["c", "d", "2.2"], ["e", "f", "3.3"]] def batch_set_data(self, indexes, data): for index, value in zip(indexes, data): @@ -46,8 +44,8 @@ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if role not in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): return None if orientation == Qt.Orientation.Horizontal: - return 'Column {}'.format(section) - return 'Row {}'.format(section) + return "Column {}".format(section) + return "Row {}".format(section) def insertColumns(self, column, count, parent=QModelIndex()): self.beginInsertColumns(parent, column, column + count) @@ -73,12 +71,12 @@ def rowCount(self, parent=QModelIndex()): def delocalize_comma_decimal_separator(x): - return x.replace(',', '.') + return x.replace(",", ".") def str_with_comma_decimal_separator(x): string = str(x) - return string.replace('.', ',') + return string.replace(".", ",") class TestCopyPasteTableView(unittest.TestCase): @@ -87,7 +85,7 @@ def setUpClass(cls): if not QApplication.instance(): QApplication() - @patch('spinetoolbox.widgets.custom_qtableview.locale.str', str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.str", str_with_comma_decimal_separator) def test_copy_single_number(self): view = CopyPasteTableView() model = _MockModel() @@ -99,7 +97,7 @@ def test_copy_single_number(self): copied = clipboard.text() self.assertEqual(copied, "1,1\r\n") - @patch('spinetoolbox.widgets.custom_qtableview.locale.str', str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.str", str_with_comma_decimal_separator) def test_copy_row_with_hidden_column(self): view = CopyPasteTableView() model = _MockModel() @@ -112,9 +110,9 @@ def test_copy_row_with_hidden_column(self): copied = clipboard.text() self.assertEqual(copied, "a\t1,1\r\n") - @patch('locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.delocalize', delocalize_comma_decimal_separator) + @patch("locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.delocalize", delocalize_comma_decimal_separator) def test_paste_single_localized_number(self): view = CopyPasteTableView() model = _MockModel() @@ -124,9 +122,9 @@ def test_paste_single_localized_number(self): self.assertTrue(view.paste()) self.assertEqual(model.index(0, 2).data(), "-1.1") - @patch('locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.delocalize', delocalize_comma_decimal_separator) + @patch("locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.delocalize", delocalize_comma_decimal_separator) def test_paste_single_localized_row(self): view = CopyPasteTableView() model = _MockModel() @@ -139,9 +137,9 @@ def test_paste_single_localized_row(self): self.assertEqual(model.index(0, 1).data(), "B") self.assertEqual(model.index(0, 2).data(), "-1.1") - @patch('locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.str', str_with_comma_decimal_separator) - @patch('spinetoolbox.widgets.custom_qtableview.locale.delocalize', delocalize_comma_decimal_separator) + @patch("locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.str", str_with_comma_decimal_separator) + @patch("spinetoolbox.widgets.custom_qtableview.locale.delocalize", delocalize_comma_decimal_separator) def test_paste_single_comma_separated_string(self): view = CopyPasteTableView() model = _MockModel() @@ -151,6 +149,38 @@ def test_paste_single_comma_separated_string(self): self.assertTrue(view.paste()) self.assertEqual(model.index(0, 2).data(), "unit,node") + def test_pasting_normal_with_column_converter(self): + view = CopyPasteTableView() + view.set_column_converter_for_pasting("Column 2", float) + model = _MockModel() + view.setModel(model) + selection_model = view.selectionModel() + selection_model.setCurrentIndex(model.index(0, 2), QItemSelectionModel.ClearAndSelect) + mock_clipboard = MagicMock() + mock_clipboard.text.return_value = "3.14" + with patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(view.paste()) + data = model.index(0, 2).data() + self.assertIsInstance(data, float) + self.assertEqual(data, 3.14) + + def test_pasting_selection_with_column_converter(self): + view = CopyPasteTableView() + view.set_column_converter_for_pasting("Column 2", float) + model = _MockModel() + view.setModel(model) + selection = QItemSelection(model.index(1, 0), model.index(1, 2)) + selection_model = view.selectionModel() + selection_model.select(selection, QItemSelectionModel.ClearAndSelect) + mock_clipboard = MagicMock() + mock_clipboard.text.return_value = "G\tH\t3.14" + with patch("spinetoolbox.widgets.custom_qtableview.QApplication.clipboard") as clipboard: + clipboard.return_value = mock_clipboard + self.assertTrue(view.paste()) + data = [model.index(1, column).data() for column in range(3)] + self.assertEqual(data, ["G", "H", 3.14]) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_DatetimeEditor.py b/tests/widgets/test_DatetimeEditor.py index 4dd416b15..3fb9b36f0 100644 --- a/tests/widgets/test_DatetimeEditor.py +++ b/tests/widgets/test_DatetimeEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the DatetimeEditor widget. -""" - +"""Unit tests for the DatetimeEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import DateTime @@ -36,5 +34,5 @@ def test_value_access(self): self.assertEqual(editor.value(), DateTime("2000-02-02T20:02")) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_DurationEditor.py b/tests/widgets/test_DurationEditor.py index f12bee694..b7e77e034 100644 --- a/tests/widgets/test_DurationEditor.py +++ b/tests/widgets/test_DurationEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the DuratonEditor widget. -""" - +"""Unit tests for the DuratonEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import Duration @@ -36,5 +34,5 @@ def test_value_access_single_duration(self): self.assertEqual(editor.value(), Duration("3M")) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_IndexedValueTableView.py b/tests/widgets/test_IndexedValueTableView.py index 73db3c00d..f821b33e3 100644 --- a/tests/widgets/test_IndexedValueTableView.py +++ b/tests/widgets/test_IndexedValueTableView.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for IndexedValueTableView class. -""" - +"""Unit tests for IndexedValueTableView class.""" import locale import unittest from PySide6.QtCore import QItemSelectionModel @@ -172,7 +170,7 @@ def test_pasted_cells_are_selected(self): selection_model = self._table_view.selectionModel() model = self._table_view.model() selection_model.select(model.index(0, 1), QItemSelectionModel.Select) - copied_data = locale.str(-1.1) + '\n' + locale.str(-2.2) + copied_data = locale.str(-1.1) + "\n" + locale.str(-2.2) QApplication.clipboard().setText(copied_data) self._table_view.paste() selected_indexes = selection_model.selectedIndexes() @@ -181,5 +179,5 @@ def test_pasted_cells_are_selected(self): self.assertTrue(model.index(1, 1) in selected_indexes) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_InstallJuliaWizard.py b/tests/widgets/test_InstallJuliaWizard.py index 1556c1e02..f4087397a 100644 --- a/tests/widgets/test_InstallJuliaWizard.py +++ b/tests/widgets/test_InstallJuliaWizard.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the KernelEditor widget. -""" - +"""Unit tests for the KernelEditor widget.""" import unittest from unittest import mock from PySide6.QtWidgets import QApplication, QWizard diff --git a/tests/widgets/test_JupyterConsoleWidget.py b/tests/widgets/test_JupyterConsoleWidget.py index 1262549dc..14c63d582 100644 --- a/tests/widgets/test_JupyterConsoleWidget.py +++ b/tests/widgets/test_JupyterConsoleWidget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the JupyterConsoleWidget. -""" - +"""Unit tests for the JupyterConsoleWidget.""" import unittest from unittest import mock from unittest.mock import MagicMock diff --git a/tests/widgets/test_KernelFetcher.py b/tests/widgets/test_KernelFetcher.py index 06010cb67..10958f2a8 100644 --- a/tests/widgets/test_KernelFetcher.py +++ b/tests/widgets/test_KernelFetcher.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the KernelFetcher class. -""" - +"""Unit tests for the KernelFetcher class.""" import unittest from PySide6.QtWidgets import QApplication from spinetoolbox.kernel_fetcher import KernelFetcher diff --git a/tests/widgets/test_MapEditor.py b/tests/widgets/test_MapEditor.py index 6237bd7cc..dc7f2dc74 100644 --- a/tests/widgets/test_MapEditor.py +++ b/tests/widgets/test_MapEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the MapEditor widget. -""" - +"""Unit tests for the MapEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import Map @@ -36,5 +34,5 @@ def test_value_access(self): self.assertEqual(editor.value(), Map(["A", "B"], [2.2, 2.1])) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_MapTableView.py b/tests/widgets/test_MapTableView.py index 903682c45..4e0e3082b 100644 --- a/tests/widgets/test_MapTableView.py +++ b/tests/widgets/test_MapTableView.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for MapTableView class. -""" - +"""Unit tests for MapTableView class.""" import csv import locale from io import StringIO diff --git a/tests/widgets/test_ParameterValueEditor.py b/tests/widgets/test_ParameterValueEditor.py index bdc0ad37f..819fb8abe 100644 --- a/tests/widgets/test_ParameterValueEditor.py +++ b/tests/widgets/test_ParameterValueEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the ParameterValueEditor widget. -""" - +"""Unit tests for the ParameterValueEditor widget.""" import unittest import dateutil.parser import numpy as np @@ -82,11 +80,11 @@ def test_editor_sets_plain_value_in_parent_model(self): self._check_parent_model_updated_when_closed(23.0) def test_editor_sets_datetime_in_parent_model(self): - time_stamp = DateTime(dateutil.parser.parse('2019-07-03T12:00')) + time_stamp = DateTime(dateutil.parser.parse("2019-07-03T12:00")) self._check_parent_model_updated_when_closed(time_stamp) def test_editor_sets_duration_in_parent_model(self): - duration = Duration(duration_to_relativedelta('3 months')) + duration = Duration(duration_to_relativedelta("3 months")) self._check_parent_model_updated_when_closed(duration) def test_editor_sets_time_pattern_in_parent_model(self): @@ -109,5 +107,5 @@ def test_editor_sets_variable_resolution_time_series_in_parent_model(self): self._check_parent_model_updated_when_closed(time_series) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_PlainParameterValueEditor.py b/tests/widgets/test_PlainParameterValueEditor.py index c273a478f..ddb642b1f 100644 --- a/tests/widgets/test_PlainParameterValueEditor.py +++ b/tests/widgets/test_PlainParameterValueEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the PlainParameterValueEditor widget. -""" - +"""Unit tests for the PlainParameterValueEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinetoolbox.widgets.plain_parameter_value_editor import PlainParameterValueEditor @@ -45,5 +43,5 @@ def test_string_value_access(self): self.assertEqual(editor.value(), "2022") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_TimePatternEditor.py b/tests/widgets/test_TimePatternEditor.py index ae011ed68..d5085e07d 100644 --- a/tests/widgets/test_TimePatternEditor.py +++ b/tests/widgets/test_TimePatternEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimePatternEditor widget. -""" - +"""Unit tests for the TimePatternEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import TimePattern @@ -36,5 +34,5 @@ def test_value_access(self): self.assertEqual(editor.value(), TimePattern(["D1-5", "D6-10"], [2.2, 2.1])) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_TimeSeriesFixedResolutionEditor.py b/tests/widgets/test_TimeSeriesFixedResolutionEditor.py index 64c2817e2..df9c828cd 100644 --- a/tests/widgets/test_TimeSeriesFixedResolutionEditor.py +++ b/tests/widgets/test_TimeSeriesFixedResolutionEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimeSeriesFixedResolutionEditor widget. -""" - +"""Unit tests for the TimeSeriesFixedResolutionEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import TimeSeriesFixedResolution @@ -38,5 +36,5 @@ def test_value_access(self): ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_TimeSeriesFixedResolutionTableView.py b/tests/widgets/test_TimeSeriesFixedResolutionTableView.py index 4e84fb759..1f58f477a 100644 --- a/tests/widgets/test_TimeSeriesFixedResolutionTableView.py +++ b/tests/widgets/test_TimeSeriesFixedResolutionTableView.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for TimeSeriesFixedResolutionTableView class. -""" - +"""Unit tests for TimeSeriesFixedResolutionTableView class.""" import locale import unittest from PySide6.QtCore import QItemSelectionModel @@ -70,7 +68,7 @@ def test_pasting_to_last_row_expands_model(self): selection_model = self._table_view.selectionModel() model = self._table_view.model() selection_model.select(model.index(3, 1), QItemSelectionModel.Select) - copied_data = locale.str(-4.4) + '\n' + locale.str(-5.5) + copied_data = locale.str(-4.4) + "\n" + locale.str(-5.5) QApplication.clipboard().setText(copied_data) self._table_view.paste() series = TimeSeriesFixedResolution("2019-08-08T15:00", "1h", [1.1, 2.2, 3.3, -4.4, -5.5], False, False) @@ -81,7 +79,7 @@ def test_paste_to_multirow_selection_limits_pasted_data(self): model = self._table_view.model() selection_model.select(model.index(0, 1), QItemSelectionModel.Select) selection_model.select(model.index(1, 1), QItemSelectionModel.Select) - copied_data = locale.str(-1.1) + '\n' + locale.str(-2.2) + '\n' + locale.str(-3.3) + copied_data = locale.str(-1.1) + "\n" + locale.str(-2.2) + "\n" + locale.str(-3.3) QApplication.clipboard().setText(copied_data) self._table_view.paste() series = TimeSeriesFixedResolution("2019-08-08T15:00", "1h", [-1.1, -2.2, 3.3, 4.4], False, False) @@ -102,7 +100,7 @@ def test_pasted_cells_are_selected(self): selection_model = self._table_view.selectionModel() model = self._table_view.model() selection_model.select(model.index(0, 1), QItemSelectionModel.Select) - copied_data = locale.str(-1.1) + '\n' + locale.str(-2.2) + copied_data = locale.str(-1.1) + "\n" + locale.str(-2.2) QApplication.clipboard().setText(copied_data) self._table_view.paste() selected_indexes = selection_model.selectedIndexes() @@ -111,5 +109,5 @@ def test_pasted_cells_are_selected(self): self.assertTrue(model.index(1, 1) in selected_indexes) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_TimeSeriesVariableResolutionEditor.py b/tests/widgets/test_TimeSeriesVariableResolutionEditor.py index 40fe37380..5b0a633af 100644 --- a/tests/widgets/test_TimeSeriesVariableResolutionEditor.py +++ b/tests/widgets/test_TimeSeriesVariableResolutionEditor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the TimeSeriesVariableResolutionEditor widget. -""" - +"""Unit tests for the TimeSeriesVariableResolutionEditor widget.""" import unittest from PySide6.QtWidgets import QApplication from spinedb_api import TimeSeriesVariableResolution @@ -47,5 +45,5 @@ def test_value_access(self): ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_custom_combobox.py b/tests/widgets/test_custom_combobox.py new file mode 100644 index 000000000..b49d6ca36 --- /dev/null +++ b/tests/widgets/test_custom_combobox.py @@ -0,0 +1,38 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for the classes in ``custom_combobox`` module. +OpenProjectDialogComboBox is tested in test_open_project_dialog module.""" +import unittest +from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtGui import QPaintEvent +from spinetoolbox.widgets.custom_combobox import CustomQComboBox, ElidedCombobox + + +class TestCustomComboBoxes(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def test_custom_combobox(self): + self.parent = QWidget() + cb = CustomQComboBox(self.parent) + cb.addItems(["a", "b", "c"]) + self.assertEqual("a", cb.itemText(0)) + self.parent.deleteLater() + + def test_elided_combobox(self): + self.parent = QWidget() + cb = ElidedCombobox(self.parent) + cb.paintEvent(QPaintEvent(cb.rect())) + self.parent.deleteLater() diff --git a/tests/widgets/test_custom_editors.py b/tests/widgets/test_custom_editors.py deleted file mode 100644 index 49e174ce6..000000000 --- a/tests/widgets/test_custom_editors.py +++ /dev/null @@ -1,120 +0,0 @@ -###################################################################################################################### -# Copyright (C) 2017-2022 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 . -###################################################################################################################### - -""" -Unit tests for custom editor widgets. -""" - -import unittest -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication, QWidget -from spinetoolbox.widgets.custom_editors import ( - CheckListEditor, - IconColorEditor, - CustomLineEditor, - ParameterValueLineEditor, - SearchBarEditor, -) -from spinetoolbox.helpers import make_icon_id -from spinetoolbox.resources_icons_rc import qInitResources -from tests.mock_helpers import q_object - - -class TestCustomLineEditor(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def test_get_set_data(self): - with q_object(QWidget()) as parent: - editor = CustomLineEditor(parent) - editor.set_data(2.3) - self.assertEqual(editor.data(), "2.3") - - -class TestParameterValueLineEditor(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def test_set_data_aligns_text_correctly(self): - with q_object(QWidget()) as parent: - editor = ParameterValueLineEditor(parent) - editor.set_data("align_left") - self.assertTrue(editor.alignment() & Qt.AlignLeft) - editor.set_data(2.3) - self.assertTrue(editor.alignment() & Qt.AlignRight) - - def test_data_convert_text_to_number(self): - with q_object(QWidget()) as parent: - editor = ParameterValueLineEditor(parent) - editor.set_data("2.3") - self.assertEqual(editor.data(), 2.3) - - -class TestSearchBarEditor(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def test_set_data(self): - with q_object(QWidget()) as parent: - editor = SearchBarEditor(parent) - editor.set_data("current", ["current", "other"]) - self.assertEqual(editor.data(), "current") - - -class TestCheckListEditor(unittest.TestCase): - @classmethod - def setUpClass(cls): - if not QApplication.instance(): - QApplication() - - def test_set_data(self): - with q_object(QWidget()) as parent: - editor = CheckListEditor(parent) - editor.set_data(["first", "second", "third"], ["first", "third"]) - self.assertEqual(editor.data(), "first,third") - - def test_toggle_selection(self): - with q_object(QWidget()) as parent: - editor = CheckListEditor(parent) - editor.set_data(["first", "second", "third"], ["first", "third"]) - self.assertEqual(editor.data(), "first,third") - index = editor.model().index(1, 0) - editor.toggle_selected(index) - self.assertEqual(editor.data(), "first,third,second") - editor.toggle_selected(index) - self.assertEqual(editor.data(), "first,third") - - -class TestIconColorEditor(unittest.TestCase): - @classmethod - def setUpClass(cls): - qInitResources() - if not QApplication.instance(): - QApplication() - - def test_set_data(self): - with q_object(QWidget()) as parent: - editor = IconColorEditor(parent) - cog_symbol = 0xF013 - gray = 0xFFAAAAAA - icon_id = make_icon_id(cog_symbol, gray) - editor.set_data(icon_id) - self.assertEqual(editor.data(), icon_id) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/widgets/test_custom_qgraphicsscene.py b/tests/widgets/test_custom_qgraphicsscene.py index 417151614..4719e360f 100644 --- a/tests/widgets/test_custom_qgraphicsscene.py +++ b/tests/widgets/test_custom_qgraphicsscene.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,13 +10,10 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for custom graphics scenes. -""" +"""Unit tests for custom graphics scenes.""" from tempfile import TemporaryDirectory import unittest from PySide6.QtWidgets import QApplication, QGraphicsRectItem -from spine_items.data_connection.data_connection import DataConnection from spinetoolbox.widgets.custom_qgraphicsscene import CustomGraphicsScene from tests.mock_helpers import clean_up_toolbox, create_toolboxui_with_project @@ -49,21 +47,6 @@ def tearDown(self): clean_up_toolbox(self._toolbox) self._temp_dir.cleanup() - def test_handle_selection_changed_synchronizes_with_project_tree(self): - project = self._toolbox.project() - dc = DataConnection("dc", "", 0.0, 0.0, self._toolbox, project) - project.add_item(dc) - scene = self._toolbox.ui.graphicsView.scene() - self.assertEqual(scene.selectedItems(), []) - dc.get_icon().setSelected(True) - self.assertIs(scene.selectedItems()[0], dc.get_icon()) - indexes = self._toolbox.ui.treeView_project.selectionModel().selectedIndexes() - self.assertEqual(len(indexes), 1) - self.assertEqual(indexes[0].data(), "dc") - current_index = self._toolbox.ui.treeView_project.selectionModel().currentIndex() - self.assertTrue(current_index.isValid()) - self.assertEqual(current_index.data(), "dc") - if __name__ == "__main__": unittest.main() diff --git a/spinetoolbox/widgets/custom_qcombobox.py b/tests/widgets/test_custom_qlineedits.py similarity index 61% rename from spinetoolbox/widgets/custom_qcombobox.py rename to tests/widgets/test_custom_qlineedits.py index 0eedf8c64..10c860433 100644 --- a/spinetoolbox/widgets/custom_qcombobox.py +++ b/tests/widgets/test_custom_qlineedits.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,20 +10,21 @@ # this program. If not, see . ###################################################################################################################### -""" -Class for a custom QComboBox. -""" +"""Unit tests for the classes in ``custom_qlineedits`` module.""" +import unittest +from PySide6.QtWidgets import QApplication, QWidget +from spinetoolbox.widgets.custom_qlineedits import PropertyQLineEdit -from PySide6.QtWidgets import QComboBox +class TestPropertyQLineEdit(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() -class CustomQComboBox(QComboBox): - """A custom QComboBox for showing kernels in Settings->Tools.""" - - def mouseMoveEvent(self, e): - """Catch mouseMoveEvent and accept it because the comboBox - popup (QListView) has mouse tracking on as default. - This makes sure the comboBox popup appears in correct - position and clicking on the combobox repeatedly does - not move the Settings window.""" - e.accept() + def test_property_qlineedit(self): + self.parent = QWidget() + le = PropertyQLineEdit(self.parent) + le.setText("abc") + self.assertEqual("abc", le.text()) + self.parent.deleteLater() diff --git a/tests/widgets/test_custom_qtextbrowser.py b/tests/widgets/test_custom_qtextbrowser.py index 8900e347a..717332532 100644 --- a/tests/widgets/test_custom_qtextbrowser.py +++ b/tests/widgets/test_custom_qtextbrowser.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,18 +10,13 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the custom QTextBrowser. -""" - +"""Unit tests for the custom QTextBrowser.""" import logging import unittest import sys from PySide6.QtWidgets import QApplication from spinetoolbox.widgets.custom_qtextbrowser import CustomQTextBrowser -# XXX: This modules just tests QTextBrowser.maximumBlockCount, so maybe we can remove it? - class TestCustomQTextBrowser(unittest.TestCase): """Tests the CustomQTextBrowser class.""" @@ -35,8 +31,8 @@ def setUpClass(cls): logging.basicConfig( stream=sys.stderr, level=logging.DEBUG, - format='%(asctime)s %(levelname)s: %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", ) def test_default_max_blocks(self): @@ -48,17 +44,17 @@ def test_append_obeys_max_blocks(self): self.assertEqual(browser.document().blockCount(), 1) browser.document().setMaximumBlockCount(5) for _ in range(5): - browser.append('test text') + browser.append("test text") self.assertEqual(browser.document().blockCount(), 5) for _ in range(5): - browser.append('new text') + browser.append("new text") self.assertEqual(browser.document().blockCount(), 5) def test_extra_blocks_removed_from_start(self): browser = CustomQTextBrowser(None) self.assertEqual(browser.document().blockCount(), 1) browser.document().setMaximumBlockCount(3) - texts = ['1', '2', '3', '4', '5'] + texts = ["1", "2", "3", "4", "5"] for t in texts: browser.append(t) self.assertEqual(browser.document().blockCount(), 3) @@ -68,5 +64,5 @@ def test_extra_blocks_removed_from_start(self): text_block = text_block.next() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_custom_qwidgets.py b/tests/widgets/test_custom_qwidgets.py index e3bbf6f75..3aef4347f 100644 --- a/tests/widgets/test_custom_qwidgets.py +++ b/tests/widgets/test_custom_qwidgets.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the models in ``custom_qwidgets`` module. -""" - +"""Unit tests for the models in ``custom_qwidgets`` module.""" import unittest from contextlib import contextmanager from PySide6.QtCore import Qt diff --git a/tests/widgets/test_indexed_value_table_context_menu.py b/tests/widgets/test_indexed_value_table_context_menu.py index e60515912..3ffd974af 100644 --- a/tests/widgets/test_indexed_value_table_context_menu.py +++ b/tests/widgets/test_indexed_value_table_context_menu.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,10 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the indexed_value_table_context_menu module. -""" - +"""Unit tests for the indexed_value_table_context_menu module.""" import unittest from unittest.mock import MagicMock from PySide6.QtWidgets import QApplication @@ -390,5 +388,5 @@ def _find_action(menu, text): return None -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_jump_properties_widget.py b/tests/widgets/test_jump_properties_widget.py index adc897e23..91eb25a46 100644 --- a/tests/widgets/test_jump_properties_widget.py +++ b/tests/widgets/test_jump_properties_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the jump properties widget. -""" +"""Unit tests for the jump properties widget.""" from tempfile import TemporaryDirectory import unittest from PySide6.QtGui import QTextCursor @@ -80,7 +79,7 @@ def _set_link(self, properties_widget): "bottom", "dc 1", "bottom", - {"type": "python-script", "script": "exit(23)"}, + {"type": "python-script", "script": "exit(23)", "specification": ""}, toolbox=self._toolbox, ) ) @@ -94,5 +93,5 @@ def _find_widget(self): return None -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_kernel_editor.py b/tests/widgets/test_kernel_editor.py index d16005aa5..6047318be 100644 --- a/tests/widgets/test_kernel_editor.py +++ b/tests/widgets/test_kernel_editor.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -9,9 +10,7 @@ # this program. If not, see . ###################################################################################################################### -""" -Unit tests for the ``kernel_editor`` module. -""" +"""Unit tests for the ``kernel_editor`` module.""" import json import pathlib import subprocess @@ -21,26 +20,27 @@ from unittest.mock import MagicMock, patch import venv from PySide6.QtWidgets import QApplication, QMessageBox, QWidget -from spine_engine.utils.helpers import resolve_julia_executable +from spine_engine.utils.helpers import resolve_default_julia_executable from spinetoolbox.widgets.kernel_editor import KernelEditorBase class MockSettingsWidget(QWidget): - qsettings = MagicMock() + def __init__(self): + super().__init__() + self.qsettings = MagicMock() class TestKernelEditorBase(unittest.TestCase): - _settings_widget = None - @classmethod def setUpClass(cls): if not QApplication.instance(): QApplication() - cls._settings_widget = MockSettingsWidget() - @classmethod - def tearDownClass(cls): - cls._settings_widget.deleteLater() + def setUp(self): + self._settings_widget = MockSettingsWidget() + + def tearDown(self): + self._settings_widget.deleteLater() def test_is_package_installed(self): self.assertTrue(KernelEditorBase.is_package_installed(sys.executable, "PySide6")) @@ -79,7 +79,7 @@ def test_make_python_kernel(self): def test_make_julia_kernel(self): """Makes a new Julia kernel if Julia is in PATH and the base project (@.) has IJulia installed. Test Julia kernel is removed in the end if available.""" - julia_exec = resolve_julia_executable("") + julia_exec = resolve_default_julia_executable() if not julia_exec: self.skipTest("Julia not found in PATH.") kernel_name = "spinetoolbox_test_make_julia_kernel" diff --git a/tests/widgets/test_notification.py b/tests/widgets/test_notification.py index 7fb59ec78..b73938eb8 100644 --- a/tests/widgets/test_notification.py +++ b/tests/widgets/test_notification.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -12,13 +13,13 @@ """Contains unit tests for the ``notification`` module.""" import unittest from unittest.mock import MagicMock, patch - from PySide6.QtCore import QAbstractAnimation from PySide6.QtWidgets import QApplication, QWidget from PySide6.QtGui import QUndoCommand, QUndoStack from spinetoolbox.widgets.notification import Notification, ChangeNotifier +@unittest.skip("Test hangs on Windows when running all tests.") class TestChangeNotifier(unittest.TestCase): @classmethod def setUpClass(cls): @@ -55,5 +56,5 @@ def test_tear_down_disconnects_signals(self): show_method.assert_not_called() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_open_project_dialog.py b/tests/widgets/test_open_project_dialog.py new file mode 100644 index 000000000..b20990fb9 --- /dev/null +++ b/tests/widgets/test_open_project_dialog.py @@ -0,0 +1,98 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for the ``open_project_dialog`` module.""" +import unittest +from unittest import mock +from tempfile import TemporaryDirectory +from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtCore import QPoint +from spinetoolbox.widgets.open_project_dialog import OpenProjectDialog + + +class TestOpenProjectDialog(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def test_open_project_dialog(self): + self._widget = QWidget() + opw = OpenProjectDialog(DummyToolbox(self._widget)) + opw.go_root_action.trigger() + opw.go_home_action.trigger() + opw.go_documents_action.trigger() + opw.go_desktop_action.trigger() + with mock.patch( + "spinetoolbox.widgets.open_project_dialog.OpenProjectDialogComboBoxContextMenu.get_action" + ) as mock_cb_context_menu: + mock_cb_context_menu.return_value = "Clear history" + opw.show_context_menu(QPoint(0, 0)) + mock_cb_context_menu.assert_called() + opw.close() + + def test_update_recents_remove_recents(self): + self._widget = QWidget() + with TemporaryDirectory() as temp_dir1: + with TemporaryDirectory() as temp_dir2: + opw = OpenProjectDialog(DummyToolbox(self._widget)) + opw.expand_and_resize(temp_dir1) + # Add path + opw.update_recents(temp_dir1, opw._qsettings) + expected_str1 = temp_dir1 + self.assertEqual(expected_str1, opw._qsettings.recent_storages) + # Add a second one + opw.update_recents(temp_dir2, opw._qsettings) + expected_str2 = f"{temp_dir2}" + "\n" + f"{temp_dir1}" + self.assertEqual(expected_str2, opw._qsettings.recent_storages) + # Try to add the same path again + opw.update_recents(temp_dir2, opw._qsettings) + self.assertEqual(expected_str2, opw._qsettings.recent_storages) + # Remove the paths one by one + opw.remove_directory_from_recents(temp_dir1, opw._qsettings) + expected_str3 = temp_dir2 + self.assertEqual(expected_str3, opw._qsettings.recent_storages) + opw.remove_directory_from_recents(temp_dir2, opw._qsettings) + expected_str4 = "" + self.assertEqual(expected_str4, opw._qsettings.recent_storages) + + +class DummyToolbox(QWidget): + def __init__(self, parent): + super().__init__(parent) + + def qsettings(self): + return MockQSettings() + + +class MockQSettings: + """Fake QSettings class for testing the update of recent project storages in Custom Open Project Dialog.""" + + def __init__(self): + self.recent_storages = None + + # noinspection PyMethodMayBeStatic, PyPep8Naming + def value(self, key, defaultValue=""): + """Returns the default value""" + if key == "appSettings/recentProjectStorages": + return self.recent_storages + return defaultValue + + # noinspection PyPep8Naming + def setValue(self, key, value): + """Returns without modifying anything.""" + if key == "appSettings/recentProjectStorages": + self.recent_storages = value + return + + def sync(self): + return True diff --git a/tests/widgets/test_plot_widget.py b/tests/widgets/test_plot_widget.py index 83aa4f4be..2c4148e95 100644 --- a/tests/widgets/test_plot_widget.py +++ b/tests/widgets/test_plot_widget.py @@ -1,5 +1,6 @@ ###################################################################################################################### # Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors # 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) @@ -10,14 +11,11 @@ ###################################################################################################################### """Unit tests for the ``plot_widget`` module.""" - import unittest from itertools import product from unittest import mock - from matplotlib.gridspec import GridSpec from PySide6.QtWidgets import QApplication - from spinedb_api.parameter_value import TimeSeriesFixedResolution from spinetoolbox.plotting import plot_data, TreeNode, turn_node_to_xy_data, convert_indexed_value_to_tree from spinetoolbox.widgets.plot_canvas import LegendPosition @@ -73,5 +71,5 @@ def test_legend_axes_placement_right(self): self.assertEqual(repr(plot_widget.canvas.legend_axes.get_gridspec()), repr(GridSpec(1, 2, width_ratios=[1, 0]))) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_plugin_manager_widgets.py b/tests/widgets/test_plugin_manager_widgets.py new file mode 100644 index 000000000..73a92a7a9 --- /dev/null +++ b/tests/widgets/test_plugin_manager_widgets.py @@ -0,0 +1,38 @@ +###################################################################################################################### +# Copyright (C) 2017-2022 Spine project consortium +# Copyright Spine Toolbox contributors +# 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 . +###################################################################################################################### + +"""Unit tests for the classes in ``plugin_manager_widgets`` module.""" +import unittest +from PySide6.QtWidgets import QApplication, QWidget +from spinetoolbox.widgets.plugin_manager_widgets import InstallPluginDialog, ManagePluginsDialog + + +class TestPluginManagerWidgets(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not QApplication.instance(): + QApplication() + + def test_install_plugins_dialog(self): + self._parent = QWidget() + d = InstallPluginDialog(self._parent) + d.populate_list(["Plugin1", "Plugin2"]) + d.close() + self._parent.deleteLater() + + def test_manage_plugins_dialog(self): + self._parent = QWidget() + d = ManagePluginsDialog(self._parent) + d.populate_list([("Plugin", True)]) + d._emit_item_removed("Plugin") + d.close() + self._parent.deleteLater() diff --git a/tests/widgets/test_select_database_items.py b/tests/widgets/test_select_database_items.py index db756f712..b17cbc6a6 100644 --- a/tests/widgets/test_select_database_items.py +++ b/tests/widgets/test_select_database_items.py @@ -8,10 +8,10 @@ # 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 . ###################################################################################################################### + """Unit tests for ``select_database_items`` module.""" import unittest from contextlib import contextmanager - from PySide6.QtWidgets import QApplication from spinetoolbox.widgets.select_database_items import SelectDatabaseItems @@ -23,7 +23,7 @@ def setUpClass(cls): QApplication() def test_restore_previously_checked_states(self): - stored_states = {"feature": True, "object": True} + stored_states = {"feature": True, "entity": True} with _select_database_items(stored_states) as widget: self.assertEqual( widget.checked_states(), @@ -31,22 +31,18 @@ def test_restore_previously_checked_states(self): "alternative": False, "entity_group": False, "entity_metadata": False, - "feature": True, "list_value": False, "metadata": False, - "object": True, - "object_class": False, + "entity": True, + "entity_class": False, + "superclass_subclass": False, + "entity_alternative": False, "parameter_definition": False, "parameter_value": False, "parameter_value_list": False, "parameter_value_metadata": False, - "relationship": False, - "relationship_class": False, "scenario": False, "scenario_alternative": False, - "tool": False, - "tool_feature": False, - "tool_feature_method": False, }, ) @@ -83,5 +79,5 @@ def _select_database_items(checked_states): widget.deleteLater() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/widgets/test_settings_widget.py b/tests/widgets/test_settings_widget.py index 77b1dfea0..c5066f7f7 100644 --- a/tests/widgets/test_settings_widget.py +++ b/tests/widgets/test_settings_widget.py @@ -12,10 +12,8 @@ """Unit tests for the ``settings_widget`` module.""" import os import unittest - from PySide6.QtCore import QSettings from PySide6.QtWidgets import QApplication - from spinetoolbox.widgets.settings_widget import SettingsWidget from tests.mock_helpers import create_toolboxui @@ -84,5 +82,5 @@ def test_defaults_for_initially_empty_app_settings(self): self._settings.endGroup() -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tool_specifications/julia-test/input/branch.csv b/tool_specifications/julia-test/input/branch.csv deleted file mode 100644 index f46473c32..000000000 --- a/tool_specifications/julia-test/input/branch.csv +++ /dev/null @@ -1,39 +0,0 @@ -f_bus,t_bus,br_r,br_x,br_b,tap,rate_a,rate_b,outage_rate,outage_duration,weighting_factor,br_status,angmin,angmax,shift,internal -1,2,0.003,0.014,0.461,0,193,200,0.24,16,1,1,-60,60,0,0 -1,3,0.055,0.211,0.057,0,208,220,0.51,10,1.2,1,-60,60,0,0 -1,5,0.022,0.085,0.023,0,208,220,0.33,10,1,1,-60,60,0,0 -2,4,0.033,0.127,0.034,0,208,220,0.39,10,1,1,-60,60,0,0 -2,6,0.05,0.192,0.052,0,208,220,0.48,10,1.3,1,-60,60,0,0 -3,9,0.031,0.119,0.032,0,208,220,0.38,10,1,1,-60,60,0,0 -3,24,0.002,0.084,0,1.015,510,600,0.02,768,1.3,1,-60,60,0,1 -4,9,0.027,0.104,0.028,0,208,220,0.36,10,1,1,-60,60,0,0 -5,10,0.023,0.088,0.024,0,208,220,0.34,10,1,1,-60,60,0,0 -6,10,0.014,0.061,2.459,0,193,200,0.33,35,1,1,-60,60,0,0 -7,8,0.016,0.061,0.017,0,208,220,0.3,10,1,1,-60,60,0,0 -8,9,0.043,0.165,0.045,0,208,220,0.44,10,1,1,-60,60,0,0 -8,10,0.043,0.165,0.045,0,208,220,0.44,10,1,1,-60,60,0,0 -9,11,0.002,0.084,0,1.03,510,600,0.02,768,1.4,1,-60,60,0,1 -9,12,0.002,0.084,0,1.03,510,600,0.02,768,1.4,1,-60,60,0,1 -10,11,0.002,0.084,0,1.015,510,600,0.02,768,1.4,1,-60,60,0,1 -10,12,0.002,0.084,0,1.015,510,600,0.02,768,1.4,1,-60,60,0,1 -11,13,0.006,0.048,0.1,0,600,625,0.4,11,1.2,1,-60,60,0,0 -11,14,0.005,0.042,0.088,0,600,625,0.39,11,1.2,1,-60,60,0,0 -12,13,0.006,0.048,0.1,0,600,625,0.4,11,1.3,1,-60,60,0,0 -12,23,0.012,0.097,0.203,0,600,625,0.52,11,1.3,1,-60,60,0,0 -13,23,0.011,0.087,0.182,0,600,625,0.49,11,1.1,1,-60,60,0,0 -14,16,0.005,0.059,0.082,0,600,625,0.38,11,1.4,1,-60,60,0,0 -15,16,0.002,0.017,0.036,0,600,625,0.33,11,1.3,1,-60,60,0,0 -15,21,0.006,0.049,0.103,0,600,625,0.41,11,0.9,1,-60,60,0,0 -15,21,0.006,0.049,0.103,0,600,625,0.41,11,0.9,1,-60,60,0,0 -15,24,0.007,0.052,0.109,0,600,625,0.41,11,1.3,1,-60,60,0,0 -16,17,0.003,0.026,0.055,0,600,625,0.35,11,1.3,1,-60,60,0,0 -16,19,0.003,0.023,0.049,0,600,625,0.34,11,1.1,1,-60,60,0,0 -17,18,0.002,0.014,0.03,0,600,625,0.32,11,1.1,1,-60,60,0,0 -17,22,0.014,0.105,0.221,0,600,625,0.54,11,1.2,1,-60,60,0,0 -18,21,0.003,0.026,0.055,0,600,625,0.35,11,0.8,1,-60,60,0,0 -18,21,0.003,0.026,0.055,0,600,625,0.35,11,0.8,1,-60,60,0,0 -19,20,0.005,0.04,0.083,0,600,625,0.38,11,0.9,1,-60,60,0,0 -19,20,0.005,0.04,0.083,0,600,625,0.38,11,0.9,1,-60,60,0,0 -20,23,0.003,0.022,0.046,0,600,625,0.34,11,0.9,1,-60,60,0,0 -20,23,0.003,0.022,0.046,0,600,625,0.34,11,0.9,1,-60,60,0,0 -21,22,0.009,0.068,0.142,0,600,625,0.45,11,0.8,1,-60,60,0,0 diff --git a/tool_specifications/julia-test/test.jl b/tool_specifications/julia-test/test.jl deleted file mode 100644 index d6b91506f..000000000 --- a/tool_specifications/julia-test/test.jl +++ /dev/null @@ -1,8 +0,0 @@ -tic() -using DataFrames -using CSV - -df = CSV.read("input/branch.csv") - -println(df) -println("elapsed time = ", toq(), " seconds") diff --git a/tool_specifications/julia-test/test.json b/tool_specifications/julia-test/test.json deleted file mode 100644 index a990fd762..000000000 --- a/tool_specifications/julia-test/test.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "Julia test", - "tooltype": "julia", - "includes": [ - "test.jl", - "input/" - ], - "description": "Loads DataFrames and reads a table.", - "inputfiles": [ - "input/branch.csv" - ], - "inputfiles_opt": [], - "outputfiles": [], - "cmdline_args": [], - "execute_in_work": true, - "includes_main_path": "." -} \ No newline at end of file diff --git a/tool_specifications/magic/input/data.gdx b/tool_specifications/magic/input/data.gdx deleted file mode 100644 index f7d836e32..000000000 Binary files a/tool_specifications/magic/input/data.gdx and /dev/null differ diff --git a/tool_specifications/magic/input/sub-folder1/dummy.gms b/tool_specifications/magic/input/sub-folder1/dummy.gms deleted file mode 100644 index b16bed206..000000000 --- a/tool_specifications/magic/input/sub-folder1/dummy.gms +++ /dev/null @@ -1 +0,0 @@ -* This file does nothing \ No newline at end of file diff --git a/tool_specifications/magic/magic.gms b/tool_specifications/magic/magic.gms deleted file mode 100644 index f6fb01b44..000000000 --- a/tool_specifications/magic/magic.gms +++ /dev/null @@ -1,175 +0,0 @@ -$Title M A G I C Power Scheduling Problem (MAGIC,SEQ=12) - -$Ontext - -A number of power stations are committed to meet demand for a particular -day. three types of generators having different operating characteristics -are available. Generating units can be shut down or operate between -minimum and maximum output levels. Units can be started up or closed down -in every demand block. - - -Garver, L L, Power Scheduling by Integer Programming, -Tariff-Rates-Power-Generation-Problem, IEEE Trans. Power Apparatus -and Systems, 81, 730-735, 1963 - -Day, R E, and Williams, H P, MAGIC: The design and use of an interactive -modeling language for mathematical programming. Tech. rep., Department -Business Studies, University of Edinburgh, 1982. - -Williams, H P, Model Building in Mathematical Programming. John Wiley -and Sons, 1978. - -$Offtext - -$iftheni %system.filesys% == UNIX $set SLASH / -$else $set SLASH \ -$endif - - - Sets t demand blocks / 12pm-6am, 6am-9am, 9am-3pm, 3pm-6pm, 6pm-12pm / - g generators / type-1, type-2, type-3 / - -Alias(g, g_); - - Parameters dem(t) demand (1000MW) / 12pm-6am 15, 6am-9am 30, 9am-3pm 25, 3pm-6pm 40, 6pm-12pm 27 / - dur(t) duration (hours) / 12pm-6am 6, 6am-9am 3, 9am-3pm 6, 3pm-6pm 3, 6pm-12pm 6 / - - Set param / min-pow '(1000MW)' - max-pow '(1000MW)' - cost-min '(¤/h)' - cost-inc '(¤/h/MW)' - start '(¤)' - number '(units)' - inv-cost '¤/kW' - / - - Parameter data(g, param) generation data ; - Parameter number(g) number of generators built; - -******************************************************************************* -$ontext - Table data(g,param) generation data - - min-pow max-pow cost-min cost-inc start number inv-cost - - type-1 .85 2.0 1000 2.0 2000 12 1000 - type-2 1.25 1.75 2600 1.3 1000 10 1200 - type-3 1.5 4.0 3000 3.0 500 5 2000 -; - -$gdxout 'input/data.gdx' -$unload data -$gdxout -$exit -$offtext -******************************************************************************* - -$gdxin 'input/data.gdx' -$loaddc data -$iftheni not %INVEST% == 'yes' - $$gdxin 'input/investments.gdx' - $$loaddc number -$endif -$gdxin - - Parameters peak peak power (1000MW) - ener(t) energy demand in load block (1000MWh) - tener total energy demanded (1000MWh) - lf load factor ; - - - - peak = smax(t, dem(t)); ener(t) = dur(t)*dem(t); tener = sum(t, ener(t)); lf = tener/(peak*24); - display peak, tener, lf, ener; - -$eject - Variables x(g,t) generator output (1000MW) - n(g,t) number of generators in use - s(g,t) number of generators started up - k(g) number of generators built - cost total operating cost (¤) - -$iftheni %USE_MIP% == 'yes' - Integer Variables k; -$ifi not %INVEST% == 'yes' - Integer Variables n; -$endif - Positive Variable s; - - Equations pow(t) demand for power (1000MW) - res(t) spinning reserve requirements (1000MW) - st(g,t) start-up definition - minu(g,t) minimum generation level (1000MW) - maxu(g,t) maximum generation level (1000MW) - totcap(g,t) total generation capacity - totcap2(g) distribute investments - cdef cost definition (¤); - - pow(t).. sum(g, x(g,t)) =g= dem(t); - - res(t).. sum(g, data(g,"max-pow")*n(g,t)) =g= 1.15*dem(t); - - st(g,t).. s(g,t) =g= n(g,t) - n(g,t--1); - - minu(g,t).. x(g,t) =g= data(g,"min-pow")*n(g,t); - - maxu(g,t).. x(g,t) =l= data(g,"max-pow")*n(g,t); - - totcap(g,t) .. n(g,t) =l= k(g); - totcap2(g) .. k(g) =l= 0.5 * sum(g_, k(g_)); - - cdef.. cost =e= sum((g,t), - dur(t)*data(g,"cost-min")*n(g,t) - + data(g,"start")*s(g,t) - + 1000*dur(t)*data(g,"cost-inc")*(x(g,t) - - data(g,"min-pow")*n(g,t)) - ) -$iftheni %INVEST% == 'yes' - + sum(g, k(g) * 1000 * data(g, 'inv-cost')) -$endif -; - -$ifi not %INVEST% == 'yes' - k.fx(g) = data(g, 'number'); - - - Model william / - pow - res - st - minu - maxu -$iftheni %INVEST% == 'yes' - totcap - totcap2 -$endif - cdef -/; - -william.optcr = 0; - -$iftheni %USE_MIP% == 'yes' - Solve william minimizing cost using mip; -$else - Solve william minimizing cost using lp; -$endif - - Parameter rep summary report; - - rep(t,"demand") = dem(t); - rep(t,"spinning") = sum(g, data(g,"max-pow")*n.l(g,t)); - rep(t,"start-ups") = sum(g, s.l(g,t)); - rep(t,"m-cost") = -pow.m(t)/dur(t)/1000; - - Display rep; - - execute_unload 'output/report.gdx', rep; - -$iftheni %INVEST% == 'yes' - number(g) = k.l(g); - execute_unload 'output/investments.gdx', number; -$endif - -*execute_unload 'output/dump.gdx'; - diff --git a/tool_specifications/magic/magic.lxi b/tool_specifications/magic/magic.lxi deleted file mode 100644 index 2ea4cabce..000000000 --- a/tool_specifications/magic/magic.lxi +++ /dev/null @@ -1,39 +0,0 @@ -B 2 C o m p i l a t i o n -B 164 Include File Summary -B 180 E x e c u t i o n -I 183 peak -I 184 tener -I 186 lf -I 187 ener -B 195 Equation Listing SOLVE william Using LP From line 147 -D 196 pow -D 211 res -D 228 st -D 243 minu -D 260 maxu -D 277 totcap -D 288 totcap2 -D 303 cdef -B 350 Column Listing SOLVE william Using LP From line 147 -E 351 x -E 377 n -E 411 s -E 430 k -E 469 cost -B 478 Model Statistics SOLVE william Using LP From line 147 -B 494 Solution Report SOLVE william Using LP From line 147 -F 523 pow -F 533 res -F 543 st -F 563 minu -F 583 maxu -F 603 totcap -F 623 totcap2 -F 629 cdef -G 632 x -G 652 n -G 672 s -G 692 k -G 700 cost -B 713 E x e c u t i o n -I 714 rep diff --git a/tool_specifications/magic/magic_invest.json b/tool_specifications/magic/magic_invest.json deleted file mode 100644 index 6fd3e7054..000000000 --- a/tool_specifications/magic/magic_invest.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "MAGIC Investments", - "tooltype": "gams", - "includes": [ - "magic.gms", - "inc/*.gms", - "input/", - "output/", - "input/sub-folder1/*.gms" - ], - "description": "M A G I C Power Scheduling Problem A number of power stations are committed to meet demand for a particular day. three types of generators having different operating characteristics are available. Generating units can be shut down or operate between minimum and maximum output levels. Units can be started up or closed down in every demand block.", - "inputfiles": [ - "input/data.gdx", - "input/required_file.txt" - ], - "inputfiles_opt": [ - "input/magic_*.dat", - "input/changes.inc" - ], - "outputfiles": [ - "magic.lst", - "output/*.*" - ], - "cmdline_args": [ - "--INVEST=yes" - ], - "execute_in_work": true, - "includes_main_path": "." -} \ No newline at end of file diff --git a/tool_specifications/magic/magic_operation.json b/tool_specifications/magic/magic_operation.json deleted file mode 100644 index 15a03d073..000000000 --- a/tool_specifications/magic/magic_operation.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "MAGIC Operation", - "tooltype": "gams", - "includes": [ - "magic.gms", - "input/", - "output/" - ], - "description": "M A G I C Power Scheduling Problem A number of power stations are committed to meet demand for a particular day. three types of generators having different operating characteristics are available. Generating units can be shut down or operate between minimum and maximum output levels. Units can be started up or closed down in every demand block.", - "inputfiles": [ - "input/investments.gdx", - "input/data.gdx" - ], - "inputfiles_opt": [ - "input/changes.inc" - ], - "outputfiles": [ - "magic.lst", - "output/report.gdx" - ], - "cmdline_args": [], - "execute_in_work": true, - "includes_main_path": "." -} \ No newline at end of file diff --git a/tool_specifications/magic/output/investments.gdx b/tool_specifications/magic/output/investments.gdx deleted file mode 100644 index 9d7261a18..000000000 Binary files a/tool_specifications/magic/output/investments.gdx and /dev/null differ diff --git a/tool_specifications/magic/output/report.gdx b/tool_specifications/magic/output/report.gdx deleted file mode 100644 index 04f944933..000000000 Binary files a/tool_specifications/magic/output/report.gdx and /dev/null differ