######################################################################################################################
# Copyright (C) 2017-2020 Spine project consortium
# This file is part of Spine Toolbox.
# Spine Toolbox is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General
# Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option)
# any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
# without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General
# Public License for more details. You should have received a copy of the GNU Lesser General Public License along with
# this program. If not, see <http://www.gnu.org/licenses/>.
######################################################################################################################
"""
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, QModelIndex
from PySide2.QtGui import QFont, QIcon
from ..helpers import busy_effect, rows_to_row_count_tuples
from ..widgets.custom_menus import ParameterViewFilterMenu
from .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] remove_selection_requested = Signal()
def __init__(self, parent, db_mngr, *db_maps):
"""Initializes model.
Args:
parent (DataStoreForm): the parent object
db_mngr (SpineDBManager): the database manager
*db_maps (DiffDatabaseMapping): the database maps included in the model
"""
super().__init__(parent, header=self._make_header())
self.db_mngr = db_mngr
self.db_maps = db_maps
self._accepted_entity_class_ids = {} # Accepted by main filter
self.remove_icon = QIcon(":/icons/menu_icons/cog_minus.svg")
self._auto_filter_menus = {}
self._auto_filter_menu_data = dict() # Maps fields to auto filter data
self._auto_filter = dict() # Maps fields to (db_map, entity_id) to accepted field ids
@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 of the entity class id in the model items (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
]
[docs] def init_model(self):
"""Initializes the model."""
super().init_model()
self._make_auto_filter_menus()
)
@busy_effect
[docs] def fetchMore(self, parent=QModelIndex()):
"""Populates filter menus as submodels are fetched."""
super().fetchMore(parent=parent)
if not self._fetch_sub_model in self.single_models:
return
self._add_data_to_filter_menus(self._fetch_sub_model)
[docs] def _get_entity_classes(self, db_map):
"""Returns a list of entity classes from the given db_map.
Args:
db_map (DiffDatabaseMapping)
Returns:
list
"""
raise NotImplementedError()
[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
"""
d = dict()
for db_map in self.db_maps:
for entity_class in self._get_entity_classes(db_map):
d.setdefault(entity_class["name"], {}).setdefault(db_map, set()).add(entity_class["id"])
models = []
for db_map_ids in d.values():
for db_map, entity_class_ids in db_map_ids.items():
for entity_class_id in entity_class_ids:
models.append(self._single_model_type(self, self.header, self.db_mngr, db_map, entity_class_id))
return models
[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 should be included in this 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._main_filter_accepts_model(model):
return False
return True
[docs] def _main_filter_accepts_model(self, model):
if self._accepted_entity_class_ids is None:
return False
if self._accepted_entity_class_ids == {}:
return True
return model.entity_class_id in self._accepted_entity_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)]
@staticmethod
[docs] def _settattr_if_different(obj, attr, val):
"""Sets the given attribute of the given object to the given value if it's different
from the one currently stored. Used for updating filters.
Returns:
bool: True if the attributed was set, False otherwise
"""
curr = getattr(obj, attr)
if curr != val:
setattr(obj, attr, val)
return True
return False
[docs] def update_main_filter(self):
"""Updates and applies the main filter."""
updated = self.update_compound_main_filter()
for model in self.single_models:
updated |= self.update_single_main_filter(model)
if updated:
self.refresh()
[docs] def update_compound_main_filter(self):
"""Updates the main filter in the compound model by setting the _accepted_entity_class_ids attribute.
Returns:
bool: True if the filter was updated, None otherwise
"""
a = bool(self._auto_filter)
self._auto_filter = dict()
b = self._settattr_if_different(
self, "_accepted_entity_class_ids", self.parent().selected_entity_class_ids(self.entity_class_type)
)
return a or b
[docs] def update_single_main_filter(self, model):
"""Updates the filter in the given single model by setting its _selected_param_def_ids attribute.
Args:
model (SingleParameterModel)
Returns:
bool: True if the filter was updated, None otherwise
"""
a = bool(model._auto_filter)
model._auto_filter.clear()
selected_param_def_ids = self.parent().selected_param_def_ids[self.entity_class_type]
if selected_param_def_ids is not None:
selected_param_def_ids = selected_param_def_ids.get((model.db_map, model.entity_class_id), set())
b = self._settattr_if_different(model, "_selected_param_def_ids", selected_param_def_ids)
return a or b
[docs] def update_auto_filter(self, field, valid_values, has_filter):
"""Updates and applies the auto filter.
Args:
field (str): the field name
valid_values (list(str)): accepted values for the field
has_filter (bool)
"""
field_menu_data = self._auto_filter_menu_data[field]
auto_filter = self._build_auto_filter(field_menu_data, valid_values, has_filter)
updated = self.update_compound_auto_filter(field, auto_filter)
for model in self.accepted_single_models():
updated |= self.update_single_auto_filter(model, field)
if updated:
self.refresh()
@staticmethod
[docs] def _build_auto_filter(field_menu_data, valid_values, has_filter):
if not has_filter:
return {} # All-pass
if not valid_values:
return None # You shall not pass
auto_filter = {}
for value in valid_values:
for db_map, entity_class_id, item_id in field_menu_data[value]:
auto_filter.setdefault(db_map, {}).setdefault(entity_class_id, []).append(item_id)
return auto_filter
[docs] def update_compound_auto_filter(self, field, auto_filter):
"""Updates 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 False
self._auto_filter[field] = auto_filter
return True
[docs] def update_single_auto_filter(self, model, field):
"""Updates 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 False
model._auto_filter[field] = values
return True
[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 _item_ids_per_class_id(self, items):
"""Returns a dict mapping entity class ids to a set of item ids.
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["id"])
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 removed dict-items keyed by DiffDatabaseMapping
"""
new_models = []
for db_map, items in db_map_data.items():
item_ids_per_class_id = self._item_ids_per_class_id(items)
for entity_class_id, ids in item_ids_per_class_id.items():
model = self._single_model_type(self, self.header, self.db_mngr, db_map, entity_class_id, lazy=False)
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)
new_models.append(model)
if item_ids_per_class_id:
self._do_add_data_to_filter_menus(db_map, items)
pos = len(self.single_models)
self.sub_models[pos:pos] = new_models
self.empty_model.receive_parameter_data_added(db_map_data)
[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():
if any(self._entity_class_id_key in item for item in items):
self._do_update_data_in_filter_menus(db_map, items)
self._emit_data_changed_for_column("parameter_name")
# TODO: parameter definition names aren't refreshed unless we emit dataChanged,
# whereas entity and class names don't need it. 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():
item_ids_per_class_id = self._item_ids_per_class_id(items)
for model in self._models_with_db_map(db_map):
removed_ids = item_ids_per_class_id.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]
if item_ids_per_class_id:
self._do_remove_data_from_filter_menus(db_map, 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 value_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]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] def _get_entity_classes(self, db_map):
return self.db_mngr.get_object_classes(db_map)
[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] def _get_entity_classes(self, db_map):
return self.db_mngr.get_relationship_classes(db_map)
[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 update_single_main_filter(self, model):
"""Update the filter for the given model."""
a = super().update_single_main_filter(model)
b = self._settattr_if_different(
model,
"_selected_entity_ids",
self.parent().selected_ent_ids[self.entity_type].get((model.db_map, model.entity_class_id), set()),
)
return a or b
[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"