######################################################################################################################
# Copyright (C) 2017-2021 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
Compound models for object parameter definitions and values.
These models concatenate several 'single' models and one 'empty' model.
:authors: M. Marin (KTH)
:date: 28.6.2019
"""
from PySide2.QtCore import Qt, Signal, Slot, QTimer
from PySide2.QtGui import QFont
from ...helpers import rows_to_row_count_tuples
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,
)
[docs]class CompoundParameterModel(CompoundWithEmptyTableModel):
"""A model that concatenates several single parameter models
and one empty parameter model.
"""
[docs] data_for_single_model_received = Signal(object, int, list)
"""Emitted by the fetcher when there's data for another single model."""
def __init__(self, parent, db_mngr, *db_maps):
"""Initializes model.
Args:
parent (SpineDBEditor): the parent object
db_mngr (SpineDBManager): the database manager
*db_maps (DiffDatabaseMapping): the database maps included in the model
"""
super().__init__(parent=parent, header=self._make_header())
self._parent = parent
self.db_mngr = db_mngr
self.db_maps = db_maps
self._filter_class_ids = {}
self._filter_valid = True
self._auto_filter_menus = {}
self._auto_filter = dict() # Maps field to db map, to entity id, to *accepted* item ids
self.data_for_single_model_received.connect(self.create_and_append_single_model)
@property
[docs] def entity_class_type(self):
"""Returns the entity_class type, either 'object_class' or 'relationship_class'.
Returns:
str
"""
raise NotImplementedError()
@property
[docs] def item_type(self):
"""Returns the parameter item type, either 'parameter_definition' or 'parameter_value'.
Returns:
str
"""
raise NotImplementedError()
@property
[docs] def _single_model_type(self):
"""
Returns a constructor for the single models.
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]
@property
[docs] def _empty_model_type(self):
"""
Returns a constructor for the empty model.
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]
@property
[docs] def entity_class_id_key(self):
"""
Returns the key corresponding to the entity_class id (either "object_class_id" or "relationship_class_id")
Returns:
str
"""
return {"object_class": "object_class_id", "relationship_class": "relationship_class_id"}[
self.entity_class_type
]
@property
[docs] def parameter_definition_id_key(self):
return {"parameter_definition": "id", "parameter_value": "parameter_id"}[self.item_type]
[docs] def init_model(self):
"""Initializes the model."""
super().init_model()
self._make_auto_filter_menus()
[docs] def _create_single_models(self):
"""Returns a list of single models for this compound model, one for each entity_class in each database.
Returns:
list
"""
return []
[docs] def _create_empty_model(self):
"""Returns the empty model for this compound model.
Returns:
EmptyParameterModel
"""
return self._empty_model_type(self, self.header, self.db_mngr)
[docs] def filter_accepts_model(self, model):
"""Returns a boolean indicating whether or not the given model passes the filter for compound model.
Args:
model (SingleParameterModel, EmptyParameterModel)
Returns:
bool
"""
if not model.can_be_filtered:
return True
if not self._auto_filter_accepts_model(model):
return False
if not self._class_filter_accepts_model(model):
return False
return True
[docs] def _class_filter_accepts_model(self, model):
if self._filter_class_ids is None:
return False
if not self._filter_class_ids:
return True
return model.entity_class_id in self._filter_class_ids.get(model.db_map, set())
[docs] def _auto_filter_accepts_model(self, model):
if None in self._auto_filter.values():
return False
for auto_filter in self._auto_filter.values():
if not auto_filter:
continue
if model.db_map not in auto_filter:
return False
if model.entity_class_id not in auto_filter[model.db_map]:
return False
return True
[docs] def accepted_single_models(self):
"""Returns a list of accepted single models by calling filter_accepts_model
on each of them, just for convenience.
Returns:
list
"""
return [m for m in self.single_models if self.filter_accepts_model(m)]
[docs] def _invalidate_filter(self):
"""Sets the filter invalid."""
self._filter_valid = False
QTimer.singleShot(0, self._refresh_if_still_invalid)
@Slot()
[docs] def _refresh_if_still_invalid(self):
if self._filter_valid:
return
self.refresh()
self._filter_valid = True
[docs] def set_filter_class_ids(self, class_ids):
if class_ids != self._filter_class_ids:
self._filter_class_ids = class_ids
self._invalidate_filter()
[docs] def set_filter_parameter_ids(self, parameter_ids):
for model in self.single_models:
if model.set_filter_parameter_ids(parameter_ids):
self._invalidate_filter()
@Slot(str, dict)
[docs] def set_auto_filter(self, field, auto_filter):
"""Updates and applies the auto filter.
Args:
field (str): the field name
auto_filter (dict): mapping db_map to entity_class id to accepted values for the field
"""
self.set_compound_auto_filter(field, auto_filter)
for model in self.accepted_single_models():
self.set_single_auto_filter(model, field)
[docs] def set_compound_auto_filter(self, field, auto_filter):
"""Sets the auto filter for given column in the compound model.
Args:
field (str): the field name
auto_filter (dict): maps tuple (database map, entity_class id) to list of accepted ids for the field
"""
if self._auto_filter.setdefault(field, {}) == auto_filter:
return
self._auto_filter[field] = auto_filter
self._invalidate_filter()
[docs] def set_single_auto_filter(self, model, field):
"""Sets the auto filter for given column in the given single model.
Args:
model (SingleParameterModel): the model
field (str): the field name
Returns:
bool: True if the auto-filtered values were updated, None otherwise
"""
values = self._auto_filter[field].get(model.db_map, {}).get(model.entity_class_id, {})
if values == model._auto_filter.get(field, {}):
return
model._auto_filter[field] = values
self._invalidate_filter()
[docs] def _row_map_for_model(self, model):
"""Returns the row map for the given model.
Reimplemented to take filter status into account.
Args:
model (SingleParameterModel, EmptyParameterModel)
Returns:
list: tuples (model, row number) for each accepted row
"""
if not self.filter_accepts_model(model):
return []
return [(model, i) for i in model.accepted_rows()]
[docs] def _models_with_db_map(self, db_map):
"""Returns a collection of single models with given db_map.
Args:
db_map (DiffDatabaseMapping)
Returns:
list
"""
return [m for m in self.single_models if m.db_map == db_map]
[docs] def receive_entity_classes_removed(self, db_map_data):
"""Runs when entity classes are removed from the dbs.
Removes sub-models for the given entity classes and dbs.
Args:
db_map_data (dict): list of removed dict-items keyed by DiffDatabaseMapping
"""
self.layoutAboutToBeChanged.emit()
for db_map, data in db_map_data.items():
ids = {x["id"] for x in data}
for model in self._models_with_db_map(db_map):
if model.entity_class_id in ids:
self.sub_models.remove(model)
self.do_refresh()
self.layoutChanged.emit()
[docs] def _items_per_class(self, items):
"""Returns a dict mapping entity_class ids to a set of items.
Args:
items (list)
Returns:
dict
"""
d = dict()
for item in items:
entity_class_id = item.get(self.entity_class_id_key)
if not entity_class_id:
continue
d.setdefault(entity_class_id, list()).append(item)
return d
[docs] def receive_parameter_data_added(self, db_map_data):
"""Runs when either parameter definitions or values are added to the dbs.
Adds necessary sub-models and initializes them with 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
"""
for db_map, items in db_map_data.items():
items_per_class = self._items_per_class(items)
for entity_class_id, class_items in items_per_class.items():
ids = [item["id"] for item in class_items]
self.data_for_single_model_received.emit(db_map, entity_class_id, ids)
self._do_add_data_to_filter_menus(db_map, class_items)
self.empty_model.receive_parameter_data_added(db_map_data)
@Slot(object, int, list)
[docs] def create_and_append_single_model(self, db_map, entity_class_id, ids):
model = self._single_model_type(self.header, self.db_mngr, db_map, entity_class_id)
model.reset_model(ids)
single_row_map = super()._row_map_for_model(model) # NOTE: super() is to get all (unfiltered) rows
self._insert_single_row_map(single_row_map)
self.sub_models.insert(len(self.single_models), model)
[docs] def receive_parameter_data_updated(self, db_map_data):
"""Runs when either parameter definitions or values are updated in the dbs.
Emits dataChanged so the parameter_name column is refreshed.
Args:
db_map_data (dict): list of updated dict-items keyed by DiffDatabaseMapping
"""
for db_map, items in db_map_data.items():
items_per_class = self._items_per_class(items)
for class_items in items_per_class.values():
self._do_update_data_in_filter_menus(db_map, class_items)
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?
[docs] def receive_parameter_data_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
"""
self.layoutAboutToBeChanged.emit()
for db_map, items in db_map_data.items():
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, {})]
if not removed_ids:
continue
removed_rows = [row for row in range(model.rowCount()) if model._main_data[row] in removed_ids]
for row, count in sorted(rows_to_row_count_tuples(removed_rows), reverse=True):
del model._main_data[row : row + count]
for class_items in items_per_class.values():
self._do_remove_data_from_filter_menus(db_map, class_items)
self.do_refresh()
self.layoutChanged.emit()
[docs] 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.DisplayRole])
[docs] def db_item(self, index):
sub_index = self.map_to_sub(index)
return sub_index.model().db_item(sub_index)
[docs] def db_map_id(self, index):
sub_index = self.map_to_sub(index)
sub_model = sub_index.model()
return sub_model.db_map, sub_model.item_id(sub_index.row())
[docs] def index_name(self, index):
item = self.db_item(index)
if item is None:
return ""
entity_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]
entity_name = item[entity_name_key].replace(",", self.db_mngr._GROUP_SEP)
return entity_name + " - " + item["parameter_name"]
[docs] 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, index=index: self.setData(index, value)
id_ = self.item_at_row(index.row())
value_field = {"parameter_value": "value", "parameter_definition": "default_value"}[self.item_type]
return lambda value, sub_model=sub_model, id_=id_: sub_model.update_items_in_db(
[{"id": id_, value_field: value}]
)
[docs] 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)
return entity_class.get("id")
[docs] def filter_by(self, rows_per_column):
for column, rows in rows_per_column.items():
field = self.headerData(column)
menu = self._auto_filter_menus[field]
accepted_values = {self.index(row, column).data(Qt.EditRole) for row in rows}
menu.set_filter_accepted_values(accepted_values)
menu._filter._apply_filter()
[docs] def filter_excluding(self, rows_per_column):
for column, rows in rows_per_column.items():
field = self.headerData(column)
menu = self._auto_filter_menus[field]
rejected_values = {self.index(row, column).data(Qt.EditRole) for row in rows}
menu.set_filter_rejected_values(rejected_values)
menu._filter._apply_filter()
[docs]class CompoundObjectParameterMixin:
"""Implements the interface for populating and filtering a compound object parameter model."""
@property
[docs] def entity_class_type(self):
return "object_class"
[docs]class CompoundRelationshipParameterMixin:
"""Implements the interface for populating and filtering a compound relationship parameter model."""
@property
[docs] def entity_class_type(self):
return "relationship_class"
[docs]class CompoundParameterDefinitionMixin:
"""Handles signals from db mngr for parameter_definition models."""
@property
[docs] def item_type(self):
return "parameter_definition"
[docs]class CompoundParameterValueMixin:
"""Handles signals from db mngr for parameter_value models."""
@property
[docs] def item_type(self):
return "parameter_value"
@property
[docs] def entity_type(self):
"""Returns the entity type, either 'object' or 'relationship'
Used by update_single_main_filter.
Returns:
str
"""
raise NotImplementedError()
[docs] def set_filter_entity_ids(self, entity_ids):
for model in self.single_models:
if model.set_filter_entity_ids(entity_ids):
self._invalidate_filter()
[docs] def set_filter_alternative_ids(self, alternative_ids):
for model in self.single_models:
if model.set_filter_alternative_ids(alternative_ids):
self._invalidate_filter()
[docs] def receive_alternatives_updated(self, db_map_data):
"""Updated alternative column
Args:
db_map_data (dict): list of updated dict-items keyed by DiffDatabaseMapping
"""
self._emit_data_changed_for_column("alternative_id")
[docs]class CompoundObjectParameterDefinitionModel(
CompoundObjectParameterMixin, CompoundParameterDefinitionMixin, CompoundParameterModel
):
"""A model that concatenates several single object parameter_definition models
and one empty object parameter_definition model.
"""
]
[docs]class CompoundRelationshipParameterDefinitionModel(
CompoundRelationshipParameterMixin, CompoundParameterDefinitionMixin, CompoundParameterModel
):
"""A model that concatenates several single relationship parameter_definition models
and one empty relationship parameter_definition model.
"""
]
[docs]class CompoundObjectParameterValueModel(
CompoundObjectParameterMixin, CompoundParameterValueMixin, CompoundParameterModel
):
"""A model that concatenates several single object parameter_value models
and one empty object parameter_value model.
"""
@property
[docs] def entity_type(self):
return "object"
[docs]class CompoundRelationshipParameterValueModel(
CompoundRelationshipParameterMixin, CompoundParameterValueMixin, CompoundParameterModel
):
"""A model that concatenates several single relationship parameter_value models
and one empty relationship parameter_value model.
"""
]
@property
[docs] def entity_type(self):
return "relationship"