From 487c9bca44127bab44bf682404e946c35a97d2bc Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 15 Oct 2024 22:12:48 +0300 Subject: [PATCH 01/47] Incorporate adding of a metric column. --- .../gui/metrics_builder_dialog.py | 141 +++++++ src/cplus_plugin/gui/metrics_builder_model.py | 195 +++++++++ src/cplus_plugin/gui/qgis_cplus_main.py | 21 + src/cplus_plugin/models/report.py | 51 ++- .../ui/activity_metrics_builder_dialog.ui | 394 ++++++++++++++++++ .../ui/qgis_cplus_main_dockwidget.ui | 120 ++++-- src/cplus_plugin/utils.py | 17 + 7 files changed, 902 insertions(+), 37 deletions(-) create mode 100644 src/cplus_plugin/gui/metrics_builder_dialog.py create mode 100644 src/cplus_plugin/gui/metrics_builder_model.py create mode 100644 src/cplus_plugin/ui/activity_metrics_builder_dialog.ui diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py new file mode 100644 index 000000000..726c4dadf --- /dev/null +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +""" +Wizard for customizing custom activity metrics table. +""" + +import os +import re +import typing + +from qgis.core import ( + Qgis, + QgsColorRamp, + QgsFillSymbolLayer, + QgsGradientColorRamp, + QgsMapLayerProxyModel, + QgsRasterLayer, +) +from qgis.gui import QgsGui, QgsMessageBar + +from qgis.PyQt import QtGui, QtWidgets + +from qgis.PyQt.uic import loadUiType + +from ..conf import Settings, settings_manager + +from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE +from .metrics_builder_model import MetricColumnListModel +from ..models.base import Activity +from ..utils import FileUtils, log, generate_random_color, open_documentation, tr + +WidgetUi, _ = loadUiType( + os.path.join(os.path.dirname(__file__), "../ui/activity_metrics_builder_dialog.ui") +) + + +class ActivityMetricsBuilder(QtWidgets.QWizard, WidgetUi): + """Wizard for customizing custom activity metrics table.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + QgsGui.enableAutoGeometryRestore(self) + + self._message_bar = QgsMessageBar() + # self.vl_notification.addWidget(self._message_bar) + + self._column_list_model = MetricColumnListModel() + + # Initialize wizard + ci_icon = FileUtils.get_icon("cplus_logo.svg") + ci_pixmap = ci_icon.pixmap(64, 64) + self.setPixmap(QtWidgets.QWizard.LogoPixmap, ci_pixmap) + + help_button = self.button(QtWidgets.QWizard.HelpButton) + help_icon = FileUtils.get_icon("mActionHelpContents_green.svg") + help_button.setIcon(help_icon) + + self.currentIdChanged.connect(self.on_page_id_changed) + self.helpRequested.connect(self.on_help_requested) + + # Intro page + banner = FileUtils.get_pixmap("metrics_illustration.svg") + self.lbl_banner.setPixmap(banner) + self.lbl_banner.setScaledContents(True) + + # Columns page + add_icon = FileUtils.get_icon("symbologyAdd.svg") + self.btn_add_column.setIcon(add_icon) + self.btn_add_column.clicked.connect(self.on_add_column) + + remove_icon = FileUtils.get_icon("symbologyRemove.svg") + self.btn_delete_column.setIcon(remove_icon) + self.btn_delete_column.setEnabled(False) + self.btn_delete_column.clicked.connect(self.on_remove_column) + + move_up_icon = FileUtils.get_icon("mActionArrowUp.svg") + self.btn_column_up.setIcon(move_up_icon) + self.btn_column_up.setEnabled(False) + self.btn_column_up.clicked.connect(self.on_move_up_column) + + move_down_icon = FileUtils.get_icon("mActionArrowDown.svg") + self.btn_column_down.setIcon(move_down_icon) + self.btn_column_down.setEnabled(False) + self.btn_column_down.clicked.connect(self.on_move_down_column) + + self.splitter.setStretchFactor(0, 25) + self.splitter.setStretchFactor(1, 75) + + self.lst_columns.setModel(self._column_list_model) + + def on_page_id_changed(self, page_id: int): + """Slot raised when the page ID changes. + + :param page_id: ID of the new page. + :type page_id: int + """ + # Update title + window_title = ( + f"{tr('Activity Metrics Wizard')} - " + f"{tr('Step')} {page_id + 1} {tr('of')} " + f"{len(self.pageIds())!s}" + ) + self.setWindowTitle(window_title) + + def on_help_requested(self): + """Slot raised when the help button has been clicked. + + Opens the online help documentation in the user's browser. + """ + open_documentation(USER_DOCUMENTATION_SITE) + + def on_add_column(self): + """Slot raised to add a new column.""" + label_text = ( + f"{tr('Specify the name of the column.')}
" + f"{tr('Any special characters will be removed.')}" + f"" + ) + column_name, ok = QtWidgets.QInputDialog.getText( + self, + tr("Set Column Name"), + label_text, + ) + + if ok and column_name: + # Remove special characters + clean_column_name = re.sub("\W+", " ", column_name) + self._column_list_model.add_new_column(clean_column_name) + + def on_remove_column(self): + """Slot raised to remove an existing column.""" + pass + + def on_move_up_column(self): + """Slot raised to move the selected column one level up.""" + pass + + def on_move_down_column(self): + """Slot raised to move the selected column one level down.""" + pass diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py new file mode 100644 index 000000000..2e8671c9d --- /dev/null +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +""" +MVC models for the metrics builder. +""" +import os +import typing + +from qgis.PyQt import QtCore, QtGui + +from ..models.report import MetricColumn + +from ..utils import FileUtils, tr + + +class MetricColumnListItem(QtGui.QStandardItem): + """Represents a single carbon layer path.""" + + def __init__(self, name_column: typing.Union[str, MetricColumn]): + super().__init__() + + self._column = None + if isinstance(name_column, str): + self._column = MetricColumn(name_column, name_column, "") + else: + self._column = name_column + + self.name = self._column.name + + column_icon = FileUtils.get_icon("table_column.svg") + self.setIcon(column_icon) + + @property + def name(self) -> str: + """Gets the name of the column. + + :returns: The name of the column. + :rtype: str + """ + return self._column.name + + @name.setter + def name(self, name: str): + """Update the column name. + + :param name: Name of the column. + :type name: str + """ + self._column.name = name + self.setText(name) + self.setToolTip(name) + + @property + def header(self) -> str: + """Gets the column header. + + :returns: The column header. + :rtype: str + """ + return self._column.header + + @header.setter + def header(self, header: str): + """Update the column header. + + :param header: Header of the column. + :type header: str + """ + self._column.header = header + + @property + def alignment(self) -> QtCore.Qt.AlignmentFlag: + """Gest the alignment of the column text. + + :returns: The alignment of the column text. + :rtype: QtCore.Qt.AlignmentFlag + """ + return self._column.alignment + + @alignment.setter + def alignment(self, alignment: QtCore.Qt.AlignmentFlag): + """Update the column alignment. + + :param alignment: Alignment of the column text. + :type alignment: QtCore.Qt.AlignmentFlag + """ + self._column.alignment = alignment + + @property + def expression(self) -> str: + """Gets the column-wide expression used by activity + metrics. + + :returns: The column-wide expression used by the activity + metrics. + :rtype: str + """ + return self._column.expression + + @expression.setter + def expression(self, expression: str): + """Set the column-wide expression to be used by the activity + metrics. + + :param expression: Column-wide expression to be used for + activity metrics. + :type expression: str + """ + self._column.expression = expression + + @property + def auto_calculated(self): + """Indicates whether the column value is auto-calculated. + + :returns: True if the column value is auto-calculated else + False. + :rtype: bool + """ + return self._column.auto_calculated + + @auto_calculated.setter + def auto_calculated(self, auto_calculated: bool): + """Set whether the column value is auto-calculated. + + :param auto_calculated: True if the column value is + auto-calculated else False. + :type auto_calculated: bool + """ + self._column.auto_calculated = auto_calculated + + @property + def is_valid(self) -> bool: + """Returns the validity status of the item. + + The name and header label should be defined. + + :returns: True if valid, else False. + :rtype: bool + """ + if not self._column.name or not self._column.header: + return False + + return True + + @property + def model(self) -> MetricColumn: + """Gets the underlying data model used in the item. + + :returns: The underlying data model used in the item. + :rtype: MetricColumn + """ + return self._column + + +class MetricColumnListModel(QtGui.QStandardItemModel): + """View model for list-based metric column objects.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setColumnCount(1) + + def add_new_column(self, name_column: typing.Union[str, MetricColumn]) -> bool: + """Adds a new column to the model. + + :param name_column: Name of the column or metric column + data model. + :type name_column: + + :returns: True if the column was successfully added + due to an already existing column with a similar name, + else False. + :rtype: bool + """ + column_item = MetricColumnListItem(name_column) + item = self.add_column(column_item) + if item is None: + return False + + return True + + def add_column( + self, column_item: MetricColumnListItem + ) -> typing.Optional[MetricColumnListItem]: + """Adds a column item to the model. + + :param column_item: Column item to be added to the model. + :type column_item: MetricColumnListItem + + :returns: The item successfully added to the model else + None if the item could not be successfully added due to + an already existing name in the model. + :rtype: MetricColumnListItem or None + """ + self.appendRow(column_item) + + return column_item diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index d31f00918..16dc91d1c 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -48,6 +48,7 @@ from qgis.utils import iface from .activity_widget import ActivityContainerWidget +from .metrics_builder_dialog import ActivityMetricsBuilder from .priority_group_widget import PriorityGroupWidget from .scenario_item_widget import ScenarioItemWidget from .progress_dialog import OnlineProgressDialog, ReportProgressDialog, ProgressDialog @@ -167,6 +168,10 @@ def __init__( self.landuse_weighted.toggled.connect(self.outputs_options_changed) self.highest_position.toggled.connect(self.outputs_options_changed) self.processing_type.toggled.connect(self.processing_options_changed) + self.chb_metric_builder.toggled.connect(self.on_use_custom_metrics) + self.btn_metric_builder.clicked.connect(self.on_show_metrics_wizard) + edit_table_icon = FileUtils.get_icon("mActionEditTable.svg") + self.btn_metric_builder.setIcon(edit_table_icon) self.load_layer_options() @@ -2420,6 +2425,22 @@ def open_settings(self): """Options the CPLUS settings in the QGIS options dialog.""" self.iface.showOptionsDialog(currentPage=OPTIONS_TITLE) + def on_use_custom_metrics(self, checked: bool): + """Slot raised when use custom metrics has been enabled or disabled. + + :param checked: True to use custom metrics else False. + :type checked: bool + """ + self.btn_metric_builder.setEnabled(checked) + + def on_show_metrics_wizard(self): + """Slot raised to show the metric customization + wizard for the scenario analysis report. + """ + metrics_builder = ActivityMetricsBuilder(self) + if metrics_builder.exec_() == QtWidgets.QDialog.Accepted: + pass + def run_report(self, progress_dialog, report_manager): """Run report generation. This should be called after the analysis is complete. diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index 8cf419598..f7570e1c0 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -3,10 +3,12 @@ """ Data models for report production.""" import dataclasses +from enum import IntEnum import typing from uuid import UUID -from qgis.core import QgsFeedback, QgsRectangle +from qgis.core import QgsFeedback, QgsLayoutTableColumn +from qgis.PyQt import QtCore from .base import Scenario, ScenarioResult @@ -103,3 +105,50 @@ class RepeatAreaDimension: columns: int width: float height: float + + +@dataclasses.dataclass +class MetricColumn: + """This class contains information required to create + custom columns in the activity table in a scenario + report. + """ + + name: str + header: str + expression: str + alignment: QtCore.Qt.AlignmentFlag = QtCore.Qt.AlignHCenter + auto_calculated: bool = False + + def to_qgs_column(self) -> QgsLayoutTableColumn: + """Convert this object to a QgsLayoutTableColumn for use + in a QgsLayoutTable. + + :returns: A layout column object containing the heading, + horizontal alignment and width specified. + :rtype: QgsLayoutTableColumn + """ + layout_column = QgsLayoutTableColumn(self.header) + layout_column.setHAlignment(self.alignment) + layout_column.setWidth(0) + + return layout_column + + +class ExpressionType(IntEnum): + """Type of expression.""" + + COLUMN = 0 + CUSTOM = 1 + + +@dataclasses.dataclass +class ActivityColumnMetric: + """This class provides granular control of the metric + applied in each activity's column. + """ + + column_name: str # Assuming each metric column will have a unique name + activity_id: str + expression: str + expression_type: ExpressionType = ExpressionType.COLUMN diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui new file mode 100644 index 000000000..0d324691b --- /dev/null +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -0,0 +1,394 @@ + + + ActivityMetricBuilder + + + + 0 + 0 + 604 + 512 + + + + Activity Metrics Wizard + + + true + + + QWizard::ModernStyle + + + QWizard::HaveHelpButton + + + Qt::RichText + + + Qt::RichText + + + + Welcome to the Activity Table Metrics Customization Tool + + + This step-by-step wizard is designed to help you customize the activity table in a scenario analysis report by using custom expressions. + + + 0 + + + + + + QFrame::NoFrame + + + QFrame::Raised + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + + + Qt::RichText + + + false + + + Qt::AlignCenter + + + + + + + + + + Qt::Vertical + + + + 20 + 353 + + + + + + + + <html><head/><body><p>Click <span style=" font-weight:600;">Next</span> to start the customization process.</p></body></html> + + + + + + + + Activity Metrics Columns + + + Specify the columns to be included in the activity table + + + 1 + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::NoFrame + + + true + + + + + 0 + 0 + 586 + 416 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + 5 + + + + QFrame::NoFrame + + + QFrame::Raised + + + + + + Add column + + + ... + + + + + + + <html><head/><body><p><span style=" font-weight:600;">Columns</span></p></body></html> + + + + + + + Qt::Horizontal + + + + 151 + 20 + + + + + + + + Move column one level up + + + ... + + + + + + + Remove column + + + ... + + + + + + + Move column one level down + + + ... + + + + + + + + + + + QFrame::NoFrame + + + QFrame::Raised + + + + + + QFrame::NoFrame + + + QFrame::Raised + + + + + + Header label + + + + + + + + + + Horizontal alignment + + + + + + + + + + Metric + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 278 + + + + + + + + <html><head/><body><p><span style=" font-weight:600;">Properties</span></p></body></html> + + + + + + + + + + + + + + + + Activity Metric + + + Customize the metric for each selected activity. Double click a cell to edit the metric. + + + 2 + + + + + 0 + 10 + 591 + 371 + + + + Customize activity metric + + + true + + + false + + + + + 20 + 30 + 551 + 331 + + + + + + + + Summary + + + Review and save the metrics configuration to be applied in the scenario analysis report. + + + 3 + + + + + + true + + + true + + + + + + + + + QgsFieldExpressionWidget + QWidget +
qgsfieldexpressionwidget.h
+
+
+ + +
diff --git a/src/cplus_plugin/ui/qgis_cplus_main_dockwidget.ui b/src/cplus_plugin/ui/qgis_cplus_main_dockwidget.ui index eef4cf2a7..8b6bd602f 100644 --- a/src/cplus_plugin/ui/qgis_cplus_main_dockwidget.ui +++ b/src/cplus_plugin/ui/qgis_cplus_main_dockwidget.ui @@ -439,34 +439,17 @@ - - - - - Qt::Horizontal - - - - 500 - 20 - - - - - - - - - 10 - 3 - - - - Run Scenario - - - - + + + Qt::Vertical + + + + 20 + 117 + + + @@ -580,18 +563,83 @@ + + + + + + Qt::Horizontal + + + + 500 + 20 + + + + + + + + + 10 + 3 + + + + Run Scenario + + + + + - - - Qt::Vertical - - + + - 20 - 117 + 0 + 0 - + + Scenario report options + + + + + + Use custom activity metrics table + + + + + + + false + + + Show activity metrics builder + + + ... + + + + + + + Qt::Horizontal + + + + 517 + 20 + + + + + + diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 39cc95d71..02e058c43 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -463,6 +463,23 @@ def get_icon(file_name: str) -> QtGui.QIcon: return QtGui.QIcon(icon_path) + @staticmethod + def get_pixmap(file_name: str) -> QtGui.QPixmap: + """Creates a pixmap based on the file name in the 'icons' folder. + + :param file_name: File name which should include the extension. + :type file_name: str + + :returns: Pixmap object matching the file name. + :rtype: QtGui.QPixmap + """ + pixmap_path = os.path.normpath(f"{FileUtils.plugin_dir()}/icons/{file_name}") + + if not os.path.exists(pixmap_path): + return QtGui.QPixmap() + + return QtGui.QPixmap(pixmap_path) + @staticmethod def report_template_path(file_name=None) -> str: """Get the absolute path to the template file with the given name. From 193f63c718ffe1ddf29cceeb948e247efb354acb Mon Sep 17 00:00:00 2001 From: Kahiu Date: Thu, 17 Oct 2024 04:31:42 +0300 Subject: [PATCH 02/47] Add column management. --- .../gui/metrics_builder_dialog.py | 92 ++++++++++++- src/cplus_plugin/gui/metrics_builder_model.py | 123 +++++++++++++++++- .../ui/activity_metrics_builder_dialog.ui | 46 ++----- 3 files changed, 221 insertions(+), 40 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 726c4dadf..bced8c52e 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -17,14 +17,14 @@ ) from qgis.gui import QgsGui, QgsMessageBar -from qgis.PyQt import QtGui, QtWidgets +from qgis.PyQt import QtCore, QtGui, QtWidgets from qgis.PyQt.uic import loadUiType from ..conf import Settings, settings_manager from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE -from .metrics_builder_model import MetricColumnListModel +from .metrics_builder_model import MetricColumnListItem, MetricColumnListModel from ..models.base import Activity from ..utils import FileUtils, log, generate_random_color, open_documentation, tr @@ -88,6 +88,9 @@ def __init__(self, parent=None): self.splitter.setStretchFactor(1, 75) self.lst_columns.setModel(self._column_list_model) + self.lst_columns.selectionModel().selectionChanged.connect( + self.on_column_selection_changed + ) def on_page_id_changed(self, page_id: int): """Slot raised when the page ID changes. @@ -126,16 +129,93 @@ def on_add_column(self): if ok and column_name: # Remove special characters clean_column_name = re.sub("\W+", " ", column_name) + column_exists = self._column_list_model.column_exists(clean_column_name) + if column_exists: + QtWidgets.QMessageBox.warning( + self, + tr("Duplicate Column Name"), + tr("There is an already existing column name"), + ) + return + self._column_list_model.add_new_column(clean_column_name) def on_remove_column(self): - """Slot raised to remove an existing column.""" - pass + """Slot raised to remove the selected column.""" + selected_items = self.selected_column_items() + for item in selected_items: + self._column_list_model.remove_column(item.name) def on_move_up_column(self): """Slot raised to move the selected column one level up.""" - pass + selected_items = self.selected_column_items() + if len(selected_items) == 0: + return + + item = selected_items[0] + row = self._column_list_model.move_column_up(item.row()) + if row == -1: + return + + # Maintain selection + self.select_column(row) def on_move_down_column(self): """Slot raised to move the selected column one level down.""" - pass + selected_items = self.selected_column_items() + if len(selected_items) == 0: + return + + item = selected_items[0] + row = self._column_list_model.move_column_down(item.row()) + if row == -1: + return + + # Maintain selection + self.select_column(row) + + def select_column(self, row: int): + """Select the column item in the specified row. + + :param row: Column item in the specified row number to be selected. + :type row: int + """ + index = self._column_list_model.index(row, 0) + if not index.isValid(): + return + + selection_model = self.lst_columns.selectionModel() + selection_model.select(index, QtCore.QItemSelectionModel.ClearAndSelect) + + def on_column_selection_changed( + self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection + ): + """Slot raised when selection in the columns view has changed. + + :param selected: Current item selection. + :type selected: QtCore.QItemSelection + + :param deselected: Previously selected items that have been + deselected. + :type deselected: QtCore.QItemSelection + """ + self.btn_delete_column.setEnabled(True) + self.btn_column_up.setEnabled(True) + self.btn_column_down.setEnabled(True) + + selected_columns = self.selected_column_items() + if len(selected_columns) != 1: + self.btn_delete_column.setEnabled(False) + self.btn_column_up.setEnabled(False) + self.btn_column_down.setEnabled(False) + + def selected_column_items(self) -> typing.List[MetricColumnListItem]: + """Returns the selected column items in the column list view. + + :returns: A collection of the selected column items. + :rtype: list + """ + selection_model = self.lst_columns.selectionModel() + idxs = selection_model.selectedRows() + + return [self._column_list_model.item(idx.row()) for idx in idxs] diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 2e8671c9d..60952b841 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -2,7 +2,7 @@ """ MVC models for the metrics builder. """ -import os +from enum import IntEnum import typing from qgis.PyQt import QtCore, QtGui @@ -151,6 +151,13 @@ def model(self) -> MetricColumn: return self._column +class MoveDirection(IntEnum): + """Move an item up or down.""" + + UP = 0 + DOWN = 1 + + class MetricColumnListModel(QtGui.QStandardItemModel): """View model for list-based metric column objects.""" @@ -190,6 +197,120 @@ def add_column( an already existing name in the model. :rtype: MetricColumnListItem or None """ + existing_column = self.column_exists(column_item.name) + if existing_column: + return None + self.appendRow(column_item) return column_item + + def column_exists(self, name: str) -> bool: + """Checks if a column with the given name exists. + + :param name: Name of the column. + :type name: str + + :returns: True if the column name exists, else False. + :rtype: bool + """ + item = self.item_from_name(name) + + if item is None: + return False + + return True + + def item_from_name(self, name: str) -> typing.Optional[MetricColumnListItem]: + """Gets the model item from the column name. + + It performs a case-insensitive search of + the first matching model item. + + :param name: Name of the column. + :type name:str + + :returns: The first matching model item if + found else None. + :rtype: MetricColumnListItem + """ + items = self.findItems(name, QtCore.Qt.MatchFixedString) + + if len(items) > 0: + return items[0] + + return None + + def remove_column(self, name: str) -> bool: + """Removes the column matching the given name. + + :param name: Name of the column to be removed. + :type name: str + + :returns: True if the column was successfully + removed else False if there is no column matching + the given name. + :rtype: bool + """ + item = self.item_from_name(name) + + if item is None: + return False + + return self.removeRows(item.row(), 1) + + def move_column_up(self, row: int) -> int: + """Moves the column item in the given row one level up. + + :param row: Column item in the given row to be moved up. + :type row: int + + :returns: New position of the column item or -1 if the column + item was not moved up. + :rtype: int + """ + return self.move_column(row, MoveDirection.UP) + + def move_column_down(self, row: int) -> int: + """Moves the column item in the given row one level down. + + :param row: Column item in the given row to be moved down. + :type row: int + + :returns: New position of the column item or -1 if the column + item was not moved down. + :rtype: int + """ + return self.move_column(row, MoveDirection.DOWN) + + def move_column(self, row: int, direction: MoveDirection) -> int: + """Moves the column item in the given row one by a level + up or down as defined in the direction. + + :param row: Position of the column item to be moved. + :type row: int + + :param direction: Direction to move the column item. + :type direction: MoveDirection + + :returns: New position of the column item or -1 if the column + item was not moved. + :rtype: int + """ + if direction == MoveDirection.UP and row < 1: + return -1 + elif direction == MoveDirection.DOWN and row >= self.rowCount() - 1: + return -1 + + item = self.takeRow(row) + if item is None: + return -1 + + if direction == MoveDirection.UP: + new_position = row - 1 + elif direction == MoveDirection.DOWN: + new_position = row + 1 + + self.insertRow(new_position, item) + + return new_position diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 0d324691b..efac07e57 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -48,16 +48,7 @@ QFrame::Raised - - 2 - - - 2 - - - 2 - - + 2 @@ -115,16 +106,7 @@ 1 - - 0 - - - 0 - - - 0 - - + 0 @@ -141,20 +123,11 @@ 0 0 586 - 416 + 429 - - 0 - - - 0 - - - 0 - - + 0 @@ -234,7 +207,14 @@ - + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::SelectRows + + @@ -283,7 +263,7 @@ - + From 74fb9c9ca93c74816fa39824b971471703d5c5a2 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Thu, 17 Oct 2024 04:33:04 +0300 Subject: [PATCH 03/47] Add icons. --- src/cplus_plugin/icons/mActionArrowDown.svg | 1 + src/cplus_plugin/icons/mActionArrowUp.svg | 1 + src/cplus_plugin/icons/mActionEditTable.svg | 1 + src/cplus_plugin/icons/mIconExpression.svg | 1 + .../icons/metrics_illustration.svg | 251 ++++++++++++++++++ src/cplus_plugin/icons/table_column.svg | 21 ++ 6 files changed, 276 insertions(+) create mode 100644 src/cplus_plugin/icons/mActionArrowDown.svg create mode 100644 src/cplus_plugin/icons/mActionArrowUp.svg create mode 100644 src/cplus_plugin/icons/mActionEditTable.svg create mode 100644 src/cplus_plugin/icons/mIconExpression.svg create mode 100644 src/cplus_plugin/icons/metrics_illustration.svg create mode 100644 src/cplus_plugin/icons/table_column.svg diff --git a/src/cplus_plugin/icons/mActionArrowDown.svg b/src/cplus_plugin/icons/mActionArrowDown.svg new file mode 100644 index 000000000..71ed78475 --- /dev/null +++ b/src/cplus_plugin/icons/mActionArrowDown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/icons/mActionArrowUp.svg b/src/cplus_plugin/icons/mActionArrowUp.svg new file mode 100644 index 000000000..5bfb3aff6 --- /dev/null +++ b/src/cplus_plugin/icons/mActionArrowUp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/icons/mActionEditTable.svg b/src/cplus_plugin/icons/mActionEditTable.svg new file mode 100644 index 000000000..339ba67e5 --- /dev/null +++ b/src/cplus_plugin/icons/mActionEditTable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/icons/mIconExpression.svg b/src/cplus_plugin/icons/mIconExpression.svg new file mode 100644 index 000000000..65ebe80b2 --- /dev/null +++ b/src/cplus_plugin/icons/mIconExpression.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/cplus_plugin/icons/metrics_illustration.svg b/src/cplus_plugin/icons/metrics_illustration.svg new file mode 100644 index 000000000..cd3439ba1 --- /dev/null +++ b/src/cplus_plugin/icons/metrics_illustration.svg @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + Activity + Column 1 + Column 2 + Column 3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/cplus_plugin/icons/table_column.svg b/src/cplus_plugin/icons/table_column.svg new file mode 100644 index 000000000..2000096c2 --- /dev/null +++ b/src/cplus_plugin/icons/table_column.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + From 1464dff47154736f80081d34874c578de5acb6e0 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 19 Oct 2024 08:52:46 +0300 Subject: [PATCH 04/47] Enable in-memory persistence of column properties. --- .../gui/metrics_builder_dialog.py | 154 +++++++++++++++++- src/cplus_plugin/icons/mIconAlignCenter.svg | 1 + src/cplus_plugin/icons/mIconAlignJustify.svg | 1 + src/cplus_plugin/icons/mIconAlignLeft.svg | 1 + src/cplus_plugin/icons/mIconAlignRight.svg | 1 + src/cplus_plugin/icons/table_column.svg | 27 ++- .../ui/activity_metrics_builder_dialog.ui | 59 ++++--- 7 files changed, 209 insertions(+), 35 deletions(-) create mode 100644 src/cplus_plugin/icons/mIconAlignCenter.svg create mode 100644 src/cplus_plugin/icons/mIconAlignJustify.svg create mode 100644 src/cplus_plugin/icons/mIconAlignLeft.svg create mode 100644 src/cplus_plugin/icons/mIconAlignRight.svg diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index bced8c52e..bf26aed61 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -26,6 +26,7 @@ from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE from .metrics_builder_model import MetricColumnListItem, MetricColumnListModel from ..models.base import Activity +from ..models.report import MetricColumn from ..utils import FileUtils, log, generate_random_color, open_documentation, tr WidgetUi, _ = loadUiType( @@ -42,11 +43,18 @@ def __init__(self, parent=None): QgsGui.enableAutoGeometryRestore(self) - self._message_bar = QgsMessageBar() - # self.vl_notification.addWidget(self._message_bar) + # Setup notification bars + self._column_message_bar = QgsMessageBar() + self.vl_column_notification.addWidget(self._column_message_bar) self._column_list_model = MetricColumnListModel() + # Add the default area column + area_metric_column = MetricColumn( + "Area", tr("Area (Ha)"), "", auto_calculated=True + ) + self._column_list_model.add_new_column(area_metric_column) + # Initialize wizard ci_icon = FileUtils.get_icon("cplus_logo.svg") ci_pixmap = ci_icon.pixmap(64, 64) @@ -87,11 +95,25 @@ def __init__(self, parent=None): self.splitter.setStretchFactor(0, 25) self.splitter.setStretchFactor(1, 75) + self.cbo_column_expression.setAllowEmptyFieldName(True) + self.cbo_column_expression.setAllowEvalErrors(False) + self.cbo_column_expression.setExpressionDialogTitle( + tr("Column Expression Builder") + ) + self.lst_columns.setModel(self._column_list_model) self.lst_columns.selectionModel().selectionChanged.connect( self.on_column_selection_changed ) + self.txt_column_name.textChanged.connect(self._on_column_header_changed) + self.cbo_column_alignment.currentIndexChanged.connect( + self._on_column_alignment_changed + ) + self.cbo_column_expression.fieldChanged.connect( + self._on_column_expression_changed + ) + def on_page_id_changed(self, page_id: int): """Slot raised when the page ID changes. @@ -113,6 +135,30 @@ def on_help_requested(self): """ open_documentation(USER_DOCUMENTATION_SITE) + def push_column_message( + self, + message: str, + level: Qgis.MessageLevel = Qgis.MessageLevel.Warning, + clear_first: bool = False, + ): + """Push a message to the notification bar in the + columns wizard page. + + :param message: Message to the show in the notification bar. + :type message: str + + :param level: Severity of the message. Warning is the default. + :type level: Qgis.MessageLevel + + :param clear_first: Clear any current messages in the notification + bar, default is False. + :type clear_first: bool + """ + if clear_first: + self._column_message_bar.clearWidgets() + + self._column_message_bar.pushMessage(message, level, 5) + def on_add_column(self): """Slot raised to add a new column.""" label_text = ( @@ -140,6 +186,13 @@ def on_add_column(self): self._column_list_model.add_new_column(clean_column_name) + item = self._column_list_model.item_from_name(column_name) + if item is None: + return + + # Select item + self.select_column(item.row()) + def on_remove_column(self): """Slot raised to remove the selected column.""" selected_items = self.selected_column_items() @@ -187,6 +240,56 @@ def select_column(self, row: int): selection_model = self.lst_columns.selectionModel() selection_model.select(index, QtCore.QItemSelectionModel.ClearAndSelect) + def load_column_properties(self, column_item: MetricColumnListItem): + """Load the properties of the column item in the corresponding + UI controls. + + :param column_item: Column item whose properties are to be loaded. + :type column_item: MetricColumnListItem + """ + # Set column properties + self.txt_column_name.blockSignals(True) + self.txt_column_name.setText(column_item.header) + self.txt_column_name.blockSignals(False) + + # Load alignment options + self.cbo_column_alignment.blockSignals(True) + + self.cbo_column_alignment.clear() + + left_icon = FileUtils.get_icon("mIconAlignLeft.svg") + self.cbo_column_alignment.addItem(left_icon, tr("Left"), QtCore.Qt.AlignLeft) + + right_icon = FileUtils.get_icon("mIconAlignRight.svg") + self.cbo_column_alignment.addItem(right_icon, tr("Right"), QtCore.Qt.AlignRight) + + center_icon = FileUtils.get_icon("mIconAlignCenter.svg") + self.cbo_column_alignment.addItem( + center_icon, tr("Center"), QtCore.Qt.AlignHCenter + ) + + justify_icon = FileUtils.get_icon("mIconAlignJustify.svg") + self.cbo_column_alignment.addItem( + justify_icon, tr("Justify"), QtCore.Qt.AlignJustify + ) + + alignment_index = self.cbo_column_alignment.findData(column_item.alignment) + if alignment_index != -1: + self.cbo_column_alignment.setCurrentIndex(alignment_index) + + self.cbo_column_alignment.blockSignals(False) + + self.cbo_column_expression.blockSignals(True) + + if column_item.auto_calculated: + self.cbo_column_expression.setEnabled(False) + else: + self.cbo_column_expression.setEnabled(True) + + self.cbo_column_expression.setExpression(column_item.expression) + + self.cbo_column_expression.blockSignals(False) + def on_column_selection_changed( self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection ): @@ -209,6 +312,13 @@ def on_column_selection_changed( self.btn_column_up.setEnabled(False) self.btn_column_down.setEnabled(False) + self.clear_column_properties() + + else: + # List view is set to single selection hence this + # condition will be for one item selected. + self.load_column_properties(selected_columns[0]) + def selected_column_items(self) -> typing.List[MetricColumnListItem]: """Returns the selected column items in the column list view. @@ -219,3 +329,43 @@ def selected_column_items(self) -> typing.List[MetricColumnListItem]: idxs = selection_model.selectedRows() return [self._column_list_model.item(idx.row()) for idx in idxs] + + def _on_column_header_changed(self, header: str): + """Slot raised when the header label has changed. + + :param header: New header label text. + :type header: str + """ + self.save_column_properties() + + def _on_column_alignment_changed(self, index: int): + """Slot raised when the column alignment has changed. + + :param index: Current index of the selected alignment. + :type index: int + """ + self.save_column_properties() + + def _on_column_expression_changed(self, field_name: str): + """Slot raised when the column expression changes. + + :param field_name: The field that has changed. + :type field_name: str + """ + self.save_column_properties() + + def save_column_properties(self): + """Updates the properties of the metric column based on the + values of the UI controls for the current selected column + item. + """ + selected_columns = self.selected_column_items() + if len(selected_columns) == 0: + return + + current_column = selected_columns[0] + current_column.header = self.txt_column_name.text() + current_column.alignment = self.cbo_column_alignment.itemData( + self.cbo_column_alignment.currentIndex() + ) + current_column.expression = self.cbo_column_expression.currentText() diff --git a/src/cplus_plugin/icons/mIconAlignCenter.svg b/src/cplus_plugin/icons/mIconAlignCenter.svg new file mode 100644 index 000000000..318084889 --- /dev/null +++ b/src/cplus_plugin/icons/mIconAlignCenter.svg @@ -0,0 +1 @@ + diff --git a/src/cplus_plugin/icons/mIconAlignJustify.svg b/src/cplus_plugin/icons/mIconAlignJustify.svg new file mode 100644 index 000000000..16f104f2c --- /dev/null +++ b/src/cplus_plugin/icons/mIconAlignJustify.svg @@ -0,0 +1 @@ + diff --git a/src/cplus_plugin/icons/mIconAlignLeft.svg b/src/cplus_plugin/icons/mIconAlignLeft.svg new file mode 100644 index 000000000..b86adec40 --- /dev/null +++ b/src/cplus_plugin/icons/mIconAlignLeft.svg @@ -0,0 +1 @@ + diff --git a/src/cplus_plugin/icons/mIconAlignRight.svg b/src/cplus_plugin/icons/mIconAlignRight.svg new file mode 100644 index 000000000..729fce7b5 --- /dev/null +++ b/src/cplus_plugin/icons/mIconAlignRight.svg @@ -0,0 +1 @@ + diff --git a/src/cplus_plugin/icons/table_column.svg b/src/cplus_plugin/icons/table_column.svg index 2000096c2..d581e056f 100644 --- a/src/cplus_plugin/icons/table_column.svg +++ b/src/cplus_plugin/icons/table_column.svg @@ -7,15 +7,28 @@ .st1{fill:#B8D637;} .st2{fill:#363636;} - - - + + + + + + C30.6,4.3,29.4,3.2,28,3.2z M29.6,12.9v0.8v4.2v0.8v4.2v0.8v2.5c0,0.9-0.7,1.6-1.6,1.6h-7.7h-1.1h-0.8H8.1H7.3H6.5H4 + c-0.9,0-1.6-0.7-1.6-1.6v-2.5v-0.8v-4.2v-0.8v-4.2v-0.8V8.8h5h0.8h10.3h0.8h10.4V12.9z M29.6,8H2.4V5.7c0-0.9,0.7-1.6,1.6-1.6H28 + c0.9,0,1.6,0.7,1.6,1.6V8z"/> + + + + + + + + + + + + diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index efac07e57..93cd33f67 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -110,6 +110,9 @@ 0 + + + QFrame::NoFrame @@ -123,7 +126,7 @@ 0 0 586 - 429 + 408 @@ -146,20 +149,20 @@ QFrame::Raised - - - - Add column - + + - ... + <html><head/><body><p><span style=" font-weight:600;">Columns</span></p></body></html> - - + + + + Remove column + - <html><head/><body><p><span style=" font-weight:600;">Columns</span></p></body></html> + ... @@ -186,20 +189,10 @@ - - - - Remove column - - - ... - - - - - + + - Move column one level down + Add column ... @@ -216,6 +209,16 @@ + + + + Move column one level down + + + ... + + + @@ -243,7 +246,11 @@ - + + + 50 + + @@ -253,7 +260,7 @@ - + @@ -262,7 +269,7 @@ - + From d944895a8522c5331bee83c41c8ea58c191c4efe Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sun, 20 Oct 2024 12:23:59 +0300 Subject: [PATCH 05/47] Validate columns page. --- .../gui/metrics_builder_dialog.py | 54 ++++++++++++++++- .../ui/activity_metrics_builder_dialog.ui | 60 +++++++++---------- 2 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index bf26aed61..c6f3019dc 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -92,8 +92,8 @@ def __init__(self, parent=None): self.btn_column_down.setEnabled(False) self.btn_column_down.clicked.connect(self.on_move_down_column) - self.splitter.setStretchFactor(0, 25) - self.splitter.setStretchFactor(1, 75) + self.splitter.setStretchFactor(0, 20) + self.splitter.setStretchFactor(1, 80) self.cbo_column_expression.setAllowEmptyFieldName(True) self.cbo_column_expression.setAllowEvalErrors(False) @@ -128,6 +128,18 @@ def on_page_id_changed(self, page_id: int): ) self.setWindowTitle(window_title) + def validateCurrentPage(self) -> bool: + """Validates the current page. + + :returns: True if the current page is valid, else False. + :rtype: bool + """ + # Columns page + if self.currentId() == 1: + return self.is_columns_page_valid() + + return True + def on_help_requested(self): """Slot raised when the help button has been clicked. @@ -163,7 +175,7 @@ def on_add_column(self): """Slot raised to add a new column.""" label_text = ( f"{tr('Specify the name of the column.')}
" - f"{tr('Any special characters will be removed.')}" + f"*{tr('Any special characters will be removed.')}" f"" ) column_name, ok = QtWidgets.QInputDialog.getText( @@ -290,6 +302,12 @@ def load_column_properties(self, column_item: MetricColumnListItem): self.cbo_column_expression.blockSignals(False) + def clear_column_properties(self): + """Clear widget values for column properties.""" + self.txt_column_name.clear() + self.cbo_column_alignment.clear() + self.cbo_column_expression.setExpression("") + def on_column_selection_changed( self, selected: QtCore.QItemSelection, deselected: QtCore.QItemSelection ): @@ -369,3 +387,33 @@ def save_column_properties(self): self.cbo_column_alignment.currentIndex() ) current_column.expression = self.cbo_column_expression.currentText() + + def is_columns_page_valid(self) -> bool: + """Validates the columns page. + + :returns: True if the columns page is valid, else False. + :rtype: bool + """ + self._column_message_bar.clearWidgets() + + if self._column_list_model.rowCount() == 0: + self.push_column_message( + tr( + "At least one column is required to use in the activity metrics table." + ) + ) + return False + + is_valid = True + + for r in range(self._column_list_model.rowCount()): + item = self._column_list_model.item(r) + if not item.is_valid: + if is_valid: + is_valid = False + + tr_msg = tr("header label is empty") + msg = f"'{item.name}' {tr_msg}." + self.push_column_message(msg) + + return is_valid diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 93cd33f67..62259c178 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -6,8 +6,8 @@ 0 0 - 604 - 512 + 574 + 486 @@ -125,8 +125,8 @@ 0 0 - 586 - 408 + 556 + 382 @@ -315,35 +315,29 @@ 2 - - - - 0 - 10 - 591 - 371 - - - - Customize activity metric - - - true - - - false - - - - - 20 - 30 - 551 - 331 - - - - + + + + + + + + Customize activity metric + + + true + + + false + + + + + + + + + From 7a1d5cc077ecdfdfc91fd9442c2930e72f267311 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 22 Oct 2024 15:52:22 +0300 Subject: [PATCH 06/47] Add columns in the activity metric table. --- src/cplus_plugin/definitions/constants.py | 2 + .../gui/metrics_builder_dialog.py | 119 ++++++++-- src/cplus_plugin/gui/metrics_builder_model.py | 223 +++++++++++++++++- src/cplus_plugin/models/report.py | 3 +- .../ui/activity_metrics_builder_dialog.ui | 2 +- 5 files changed, 320 insertions(+), 29 deletions(-) diff --git a/src/cplus_plugin/definitions/constants.py b/src/cplus_plugin/definitions/constants.py index 3ff2bf229..3f02d8632 100644 --- a/src/cplus_plugin/definitions/constants.py +++ b/src/cplus_plugin/definitions/constants.py @@ -18,6 +18,8 @@ ACTIVITY_WEIGHTED_GROUP_NAME = "Weighted Activity Maps" NCS_PATHWAYS_GROUP_LAYER_NAME = "NCS Pathways Maps" +ACTIVITY_NAME = "Activity" + # Attribute names CARBON_COEFFICIENT_ATTRIBUTE = "carbon_coefficient" CARBON_PATHS_ATTRIBUTE = "carbon_paths" diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index c6f3019dc..c2bb43bed 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -24,7 +24,11 @@ from ..conf import Settings, settings_manager from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE -from .metrics_builder_model import MetricColumnListItem, MetricColumnListModel +from .metrics_builder_model import ( + ActivityMetricTableModel, + MetricColumnListItem, + MetricColumnListModel, +) from ..models.base import Activity from ..models.report import MetricColumn from ..utils import FileUtils, log, generate_random_color, open_documentation, tr @@ -49,11 +53,7 @@ def __init__(self, parent=None): self._column_list_model = MetricColumnListModel() - # Add the default area column - area_metric_column = MetricColumn( - "Area", tr("Area (Ha)"), "", auto_calculated=True - ) - self._column_list_model.add_new_column(area_metric_column) + self._activity_metric_table_model = ActivityMetricTableModel() # Initialize wizard ci_icon = FileUtils.get_icon("cplus_logo.svg") @@ -114,6 +114,25 @@ def __init__(self, parent=None): self._on_column_expression_changed ) + # Add the default area column + area_metric_column = MetricColumn( + "Area", tr("Area (Ha)"), "", auto_calculated=True + ) + area_column_item = MetricColumnListItem(area_metric_column) + self.add_column_item(area_column_item) + + # Activity metrics page + self.tb_activity_metrics.setModel(self._activity_metric_table_model) + + @property + def column_list_model(self) -> MetricColumnListModel: + """Gets the columns list model used in the wizard. + + :returns: The columns list model used in the model. + :rtype: MetricColumnListModel + """ + return self._column_list_model + def on_page_id_changed(self, page_id: int): """Slot raised when the page ID changes. @@ -128,6 +147,24 @@ def on_page_id_changed(self, page_id: int): ) self.setWindowTitle(window_title) + def initializePage(self, page_id: int): + """Initialize wizard page prior to loading. + + :param page_id: ID of the wizard page. + :type page_id: int + """ + # Activity metrics page + if page_id == 2: + # If expression is not specified for at + # least one column then enable the groupbox. + for item in self._column_list_model.column_items: + if ( + not item.expression + and not self.gb_custom_activity_metric.isChecked() + ): + self.gb_custom_activity_metric.setChecked(True) + break + def validateCurrentPage(self) -> bool: """Validates the current page. @@ -196,21 +233,36 @@ def on_add_column(self): ) return - self._column_list_model.add_new_column(clean_column_name) + column_item = MetricColumnListItem(clean_column_name) + self.add_column_item(column_item) - item = self._column_list_model.item_from_name(column_name) - if item is None: - return + def add_column_item(self, item: MetricColumnListItem): + """Adds a metric column item. + + :param item: Metrics column item to be added. + :type item: MetricColumnListItem + """ + self._column_list_model.add_column(item) - # Select item - self.select_column(item.row()) + # Select item + self.select_column(item.row()) + + # Add column to activity metrics table + self._activity_metric_table_model.add_column(item.model) + self.resize_activity_metrics_table() def on_remove_column(self): """Slot raised to remove the selected column.""" selected_items = self.selected_column_items() for item in selected_items: + index = item.row() self._column_list_model.remove_column(item.name) + # Remove corresponding column in activity metrics table + self._activity_metric_table_model.remove_column(index) + + self.resize_activity_metrics_table() + def on_move_up_column(self): """Slot raised to move the selected column one level up.""" selected_items = self.selected_column_items() @@ -218,13 +270,19 @@ def on_move_up_column(self): return item = selected_items[0] - row = self._column_list_model.move_column_up(item.row()) + current_row = item.row() + row = self._column_list_model.move_column_up(current_row) if row == -1: return # Maintain selection self.select_column(row) + # Move corresponding column in the activity metrics table. + # We have normalized it to reflect the position in the + # metrics table. + self._activity_metric_table_model.move_column_left(current_row + 1) + def on_move_down_column(self): """Slot raised to move the selected column one level down.""" selected_items = self.selected_column_items() @@ -232,13 +290,19 @@ def on_move_down_column(self): return item = selected_items[0] - row = self._column_list_model.move_column_down(item.row()) + current_row = item.row() + row = self._column_list_model.move_column_down(current_row) if row == -1: return # Maintain selection self.select_column(row) + # Move corresponding column in the activity metrics + # table. We have normalized it to reflect the position + # in the metrics table. + self._activity_metric_table_model.move_column_right(current_row + 1) + def select_column(self, row: int): """Select the column item in the specified row. @@ -388,6 +452,11 @@ def save_column_properties(self): ) current_column.expression = self.cbo_column_expression.currentText() + # Update column properties in activity metrics table + self._activity_metric_table_model.update_column_properties( + current_column.row(), current_column.model + ) + def is_columns_page_valid(self) -> bool: """Validates the columns page. @@ -399,15 +468,15 @@ def is_columns_page_valid(self) -> bool: if self._column_list_model.rowCount() == 0: self.push_column_message( tr( - "At least one column is required to use in the activity metrics table." + "At least one column is required to use in the activity " + "metrics table." ) ) return False is_valid = True - for r in range(self._column_list_model.rowCount()): - item = self._column_list_model.item(r) + for item in self._column_list_model.column_items: if not item.is_valid: if is_valid: is_valid = False @@ -417,3 +486,19 @@ def is_columns_page_valid(self) -> bool: self.push_column_message(msg) return is_valid + + def resize_activity_metrics_table(self): + """Resize column width of activity metrics table for the + entire width to be occupied. + + Use a reasonable size if the table has only one column. + """ + if self._activity_metric_table_model.columnCount() == 1: + self.tb_activity_metrics.setColumnWidth(0, 120) + return + + width = self.tb_activity_metrics.width() + # Make all columns have the same width + column_count = self._activity_metric_table_model.columnCount() + for c in range(column_count): + self.tb_activity_metrics.setColumnWidth(c, int(width / float(column_count))) diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 60952b841..d36d2f7d5 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -7,6 +7,9 @@ from qgis.PyQt import QtCore, QtGui +from ..definitions.constants import ACTIVITY_NAME + +from ..models.base import Activity from ..models.report import MetricColumn from ..utils import FileUtils, tr @@ -151,20 +154,40 @@ def model(self) -> MetricColumn: return self._column -class MoveDirection(IntEnum): +class VerticalMoveDirection(IntEnum): """Move an item up or down.""" UP = 0 DOWN = 1 +class HorizontalMoveDirection(IntEnum): + """Move an item left or right.""" + + LEFT = 0 + RIGHT = 1 + + class MetricColumnListModel(QtGui.QStandardItemModel): """View model for list-based metric column objects.""" + column_added = QtCore.pyqtSignal(MetricColumnListItem) + column_removed = QtCore.pyqtSignal(int) + column_moved = QtCore.pyqtSignal(MetricColumnListItem, VerticalMoveDirection) + def __init__(self, parent=None): super().__init__(parent) self.setColumnCount(1) + @property + def column_items(self) -> typing.List[MetricColumnListItem]: + """Gets all the column items in the model. + + :returns: All the column items in the model. + :rtype: typing.List[MetricColumnListItem] + """ + return [self.item(r) for r in range(self.rowCount())] + def add_new_column(self, name_column: typing.Union[str, MetricColumn]) -> bool: """Adds a new column to the model. @@ -203,6 +226,8 @@ def add_column( self.appendRow(column_item) + self.column_added.emit(column_item) + return column_item def column_exists(self, name: str) -> bool: @@ -257,7 +282,12 @@ def remove_column(self, name: str) -> bool: if item is None: return False - return self.removeRows(item.row(), 1) + status = self.removeRows(item.row(), 1) + + if status: + self.column_removed.emit(item) + + return status def move_column_up(self, row: int) -> int: """Moves the column item in the given row one level up. @@ -269,7 +299,7 @@ def move_column_up(self, row: int) -> int: item was not moved up. :rtype: int """ - return self.move_column(row, MoveDirection.UP) + return self.move_column(row, VerticalMoveDirection.UP) def move_column_down(self, row: int) -> int: """Moves the column item in the given row one level down. @@ -281,9 +311,9 @@ def move_column_down(self, row: int) -> int: item was not moved down. :rtype: int """ - return self.move_column(row, MoveDirection.DOWN) + return self.move_column(row, VerticalMoveDirection.DOWN) - def move_column(self, row: int, direction: MoveDirection) -> int: + def move_column(self, row: int, direction: VerticalMoveDirection) -> int: """Moves the column item in the given row one by a level up or down as defined in the direction. @@ -291,26 +321,199 @@ def move_column(self, row: int, direction: MoveDirection) -> int: :type row: int :param direction: Direction to move the column item. - :type direction: MoveDirection + :type direction: VerticalMoveDirection :returns: New position of the column item or -1 if the column item was not moved. :rtype: int """ - if direction == MoveDirection.UP and row < 1: + if direction == VerticalMoveDirection.UP and row < 1: return -1 - elif direction == MoveDirection.DOWN and row >= self.rowCount() - 1: + elif direction == VerticalMoveDirection.DOWN and row >= self.rowCount() - 1: return -1 item = self.takeRow(row) if item is None: return -1 - if direction == MoveDirection.UP: + if direction == VerticalMoveDirection.UP: new_position = row - 1 - elif direction == MoveDirection.DOWN: + elif direction == VerticalMoveDirection.DOWN: new_position = row + 1 self.insertRow(new_position, item) + self.column_moved.emit(item, direction) + return new_position + + +class ActivityNameTableItem(QtGui.QStandardItem): + """Represents an activity name in the metrics table..""" + + def __init__(self, name: str): + super().__init__() + + self.setEditable(False) + + self.setTextAlignment(QtCore.Qt.AlignCenter) + + background = self.background() + background.setColor(QtCore.Qt.lightGray) + background.setStyle(QtCore.Qt.SolidPattern) + + +class ActivityMetricTableModel(QtGui.QStandardItemModel): + """View model for activity metrics in a table.""" + + def __init__(self, parent=None, columns: typing.List[MetricColumn] = None): + super().__init__(parent) + + self.setColumnCount(1) + # Add default activity name header + self.setHorizontalHeaderLabels([tr(ACTIVITY_NAME)]) + + self._metric_columns = [] + if columns is not None: + self._metric_columns = columns + + @property + def metric_columns(self) -> typing.List[MetricColumn]: + """Gets the metric columns used in the model to + define the headers. + + :returns: Metric columns used in the model. + :rtype: typing.List[MetricColumn] + """ + return list(self._metric_columns) + + def add_column(self, column: MetricColumn): + """Adds a column to the model based on the information + in the metric column. + + :param column: Metric column containing information + for defining the new column. + :type column: MetricColumn + """ + headers = [ + self.headerData(c, QtCore.Qt.Horizontal) for c in range(self.columnCount()) + ] + headers.append(column.header) + self.setHorizontalHeaderLabels(headers) + self._metric_columns.append(column) + + def remove_column(self, index: int) -> bool: + """Remove the column at the specified index. + + The index will be normalized to reflect the first + metric column since index zero is reserved for the + activity name column which is fixed. + + :param index: Index of the column to be removed. + :type index: int + + :returns: True if the column was successfully + removed else False. + :rtype: bool + """ + if index == -1: + return False + + model_index = index + 1 + + status = self.removeColumns(model_index, 1) + + del self._metric_columns[index] + + return status + + def update_column_properties(self, index: int, column: MetricColumn): + """Updates the properties of an underlying metric column + in the model. + + :param index: Index of the column to the updated. + :type index: int + + :param column: Updated column metric object. + :type column: MetricColumn + """ + if index == -1: + return False + + # Update header + model_index = index + 1 + self.setHeaderData( + model_index, QtCore.Qt.Horizontal, column.header, QtCore.Qt.DisplayRole + ) + self._metric_columns[index] = column + + def add_activity(self, activity: Activity) -> bool: + """Adds an activity row in the activity metrics table. + + :param activity: Activity to be added. + :type activity: Activity + + :returns: True if the activity was successfully added + else False. + :rtype: bool + """ + pass + + def move_column( + self, current_index: int, direction: HorizontalMoveDirection + ) -> int: + """MOve the column in the specified index left or right depending on the + move direction. + + :param current_index: Index of the column to be moved. + :type current_index: int + + :param direction: Direction to move the column, either left or right. + :type direction: HorizontalMoveDirection + + :returns: New position of the column or -1 if the column + item was not moved. + :rtype: int + """ + # The activity name column will always be on the extreme left (LTR) + if current_index <= 1 or current_index >= self.columnCount() - 1: + return -1 + + if direction == HorizontalMoveDirection.LEFT: + new_index = current_index - 1 + else: + new_index = current_index + 1 + + # Move items + column_items = self.takeColumn(current_index) + self.insertColumn(new_index, column_items) + + # Move column header + header_item = self.takeHorizontalHeaderItem(current_index) + self.setHorizontalHeaderItem(new_index, header_item) + + return new_index + + def move_column_left(self, current_index: int) -> int: + """Convenience method for moving a column to the left. + + :param current_index: Index of the column to be moved. + :type current_index: int + + :returns: New position of the column or -1 if the column + item was not moved. + :rtype: int + """ + return self.move_column(current_index, HorizontalMoveDirection.LEFT) + + def move_column_right(self, current_index: int) -> int: + """Convenience method for moving a column to the right. + + :param current_index: Index of the column to be moved. + :type current_index: int + + :returns: New position of the column or -1 if the column + item was not moved. + :rtype: int + """ + return self.move_column(current_index, HorizontalMoveDirection.RIGHT) diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index f7570e1c0..1ad9896be 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -10,7 +10,7 @@ from qgis.core import QgsFeedback, QgsLayoutTableColumn from qgis.PyQt import QtCore -from .base import Scenario, ScenarioResult +from .base import Activity, Scenario, ScenarioResult @dataclasses.dataclass @@ -152,3 +152,4 @@ class ActivityColumnMetric: activity_id: str expression: str expression_type: ExpressionType = ExpressionType.COLUMN + activity: Activity = None diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 62259c178..4d03632dc 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -248,7 +248,7 @@ - 50 + 20 From 296fe05b9d3807ec5af426558a5e7e768a641742 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 22 Oct 2024 19:33:41 +0300 Subject: [PATCH 07/47] Fix moving columns in activity metrics table. --- src/cplus_plugin/gui/metrics_builder_model.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index d36d2f7d5..2df4bc789 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -12,7 +12,7 @@ from ..models.base import Activity from ..models.report import MetricColumn -from ..utils import FileUtils, tr +from ..utils import FileUtils, log, tr class MetricColumnListItem(QtGui.QStandardItem): @@ -332,8 +332,8 @@ def move_column(self, row: int, direction: VerticalMoveDirection) -> int: elif direction == VerticalMoveDirection.DOWN and row >= self.rowCount() - 1: return -1 - item = self.takeRow(row) - if item is None: + items = self.takeRow(row) + if items is None or len(items) == 0: return -1 if direction == VerticalMoveDirection.UP: @@ -341,9 +341,9 @@ def move_column(self, row: int, direction: VerticalMoveDirection) -> int: elif direction == VerticalMoveDirection.DOWN: new_position = row + 1 - self.insertRow(new_position, item) + self.insertRow(new_position, items[0]) - self.column_moved.emit(item, direction) + self.column_moved.emit(items[0], direction) return new_position @@ -462,7 +462,7 @@ def add_activity(self, activity: Activity) -> bool: def move_column( self, current_index: int, direction: HorizontalMoveDirection ) -> int: - """MOve the column in the specified index left or right depending on the + """Move the column in the specified index left or right depending on the move direction. :param current_index: Index of the column to be moved. @@ -476,7 +476,13 @@ def move_column( :rtype: int """ # The activity name column will always be on the extreme left (LTR) - if current_index <= 1 or current_index >= self.columnCount() - 1: + if current_index <= 1 and direction == HorizontalMoveDirection.LEFT: + return -1 + + if ( + current_index >= self.columnCount() - 1 + and direction == HorizontalMoveDirection.RIGHT + ): return -1 if direction == HorizontalMoveDirection.LEFT: @@ -484,12 +490,10 @@ def move_column( else: new_index = current_index + 1 - # Move items + # Move header and items + header_item = self.takeHorizontalHeaderItem(current_index) column_items = self.takeColumn(current_index) self.insertColumn(new_index, column_items) - - # Move column header - header_item = self.takeHorizontalHeaderItem(current_index) self.setHorizontalHeaderItem(new_index, header_item) return new_index From adaf71cff867f8d0f156bb98a9af4705d4150b22 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Thu, 24 Oct 2024 16:53:40 +0300 Subject: [PATCH 08/47] Add activity column. --- src/cplus_plugin/gui/metrics_builder_model.py | 36 +++++++++++++++++-- .../ui/activity_metrics_builder_dialog.ui | 2 +- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 2df4bc789..bed93fb53 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -351,17 +351,28 @@ def move_column(self, row: int, direction: VerticalMoveDirection) -> int: class ActivityNameTableItem(QtGui.QStandardItem): """Represents an activity name in the metrics table..""" - def __init__(self, name: str): + def __init__(self, activity: Activity): super().__init__() - self.setEditable(False) + self._activity = activity + self.setEditable(False) + self.setText(activity.name) self.setTextAlignment(QtCore.Qt.AlignCenter) background = self.background() background.setColor(QtCore.Qt.lightGray) background.setStyle(QtCore.Qt.SolidPattern) + @property + def activity(self) -> Activity: + """Gets the activity model in the item. + + :returns: The activity model in the item. + :rtype: Activity + """ + return self._activity + class ActivityMetricTableModel(QtGui.QStandardItemModel): """View model for activity metrics in a table.""" @@ -457,7 +468,26 @@ def add_activity(self, activity: Activity) -> bool: else False. :rtype: bool """ - pass + # Check if there is a similar activity + matching_activities = [ + act for act in self.activities if act.uuid == activity.uuid + ] + if len(matching_activities) > 0: + return False + + activity_item = ActivityNameTableItem(activity) + self.appendRow(activity_item) + + return True + + @property + def activities(self) -> typing.List[Activity]: + """Gets all the activities in the model. + + :returns: All activities in the model. + :rtype: typing.List[Activity] + """ + return [self.item(r, 0).activity for r in self.rowCount()] def move_column( self, current_index: int, direction: HorizontalMoveDirection diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 4d03632dc..40a93c0a0 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -33,7 +33,7 @@ Welcome to the Activity Table Metrics Customization Tool - This step-by-step wizard is designed to help you customize the activity table in a scenario analysis report by using custom expressions. + This step-by-step wizard is designed to help you easily customize the activity table in the scenario analysis report by using custom expressions. 0 From 0fd3256195e4881937f7def0cd512bb9af071df8 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 26 Oct 2024 07:47:04 +0300 Subject: [PATCH 09/47] Incorporate addition of activities. --- .../gui/metrics_builder_dialog.py | 56 ++++++++++++++++++- src/cplus_plugin/gui/metrics_builder_model.py | 3 +- src/cplus_plugin/gui/qgis_cplus_main.py | 32 ++++++----- 3 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index c2bb43bed..bcd5729da 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -41,12 +41,16 @@ class ActivityMetricsBuilder(QtWidgets.QWizard, WidgetUi): """Wizard for customizing custom activity metrics table.""" - def __init__(self, parent=None): + def __init__(self, parent=None, activities=None): super().__init__(parent) self.setupUi(self) QgsGui.enableAutoGeometryRestore(self) + self._activities = [] + if activities is not None: + self._activities = activities + # Setup notification bars self._column_message_bar = QgsMessageBar() self.vl_column_notification.addWidget(self._column_message_bar) @@ -124,6 +128,9 @@ def __init__(self, parent=None): # Activity metrics page self.tb_activity_metrics.setModel(self._activity_metric_table_model) + # Update activities if specified + self._update_activities() + @property def column_list_model(self) -> MetricColumnListModel: """Gets the columns list model used in the wizard. @@ -133,6 +140,36 @@ def column_list_model(self) -> MetricColumnListModel: """ return self._column_list_model + @property + def activity_table_model(self) -> ActivityMetricTableModel: + """Gets the activity table model used to show the metric for + each activity and column. + + :returns: The activity table model. + :rtype: ActivityMetricTableModel + """ + return self._activity_metric_table_model + + @property + def activities(self) -> typing.List[Activity]: + """Gets the activities in the model. + + :returns: All the activities in the model. + :rtype: + """ + return self._activities + + @activities.setter + def activities(self, activities: typing.List[Activity]): + """Sets the activities to be used in the model. + + :param activities: Activities to be used in the model i.e. + those whose metrics will be used in the customization. + :type activities: typing.List[Activity] + """ + self._activities = activities + self._update_activities() + def on_page_id_changed(self, page_id: int): """Slot raised when the page ID changes. @@ -184,6 +221,23 @@ def on_help_requested(self): """ open_documentation(USER_DOCUMENTATION_SITE) + def clear_activities(self): + """Removes all activities in the activity metrics table.""" + self._activity_metric_table_model.removeRows( + 0, self._activity_metric_table_model.rowCount() + ) + + def _update_activities(self): + """Update the list of activities in the activity metrics + table. + + Clears any existing activities. + """ + self.clear_activities() + + for activity in self.activities: + self._activity_metric_table_model.add_activity(activity) + def push_column_message( self, message: str, diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index bed93fb53..929499e5b 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -363,6 +363,7 @@ def __init__(self, activity: Activity): background = self.background() background.setColor(QtCore.Qt.lightGray) background.setStyle(QtCore.Qt.SolidPattern) + self.setBackground(background) @property def activity(self) -> Activity: @@ -487,7 +488,7 @@ def activities(self) -> typing.List[Activity]: :returns: All activities in the model. :rtype: typing.List[Activity] """ - return [self.item(r, 0).activity for r in self.rowCount()] + return [self.item(r, 0).activity for r in range(self.rowCount())] def move_column( self, current_index: int, direction: HorizontalMoveDirection diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 16dc91d1c..626460006 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -78,6 +78,7 @@ from .scenario_dialog import ScenarioDialog from ..models.base import ( + Activity, PriorityLayerType, ) from ..models.financial import ActivityNpv @@ -844,11 +845,7 @@ def on_manage_npv_pwls(self): # Get CRS and pixel size from at least one of the selected # NCS pathways. - selected_activities = [ - item.activity - for item in self.activity_widget.selected_activity_items() - if item.isEnabled() - ] + selected_activities = self.selected_activities() if len(selected_activities) == 0: log( message=tr( @@ -1577,11 +1574,7 @@ def run_analysis(self): group_layer_dict["layers"].append(layer.get("name")) self.analysis_priority_layers_groups.append(group_layer_dict) - self.analysis_activities = [ - item.activity - for item in self.activity_widget.selected_activity_items() - if item.isEnabled() - ] + self.analysis_activities = self.selected_activities() self.analysis_weighted_ims = [] @@ -1795,6 +1788,18 @@ def run_analysis(self): ) ) + def selected_activities(self) -> typing.List[Activity]: + """Gets the collection of selected activities. + + :returns: A list of selected activities. + :rtype: typing.List[Activity] + """ + return [ + item.activity + for item in self.activity_widget.selected_activity_items() + if item.isEnabled() + ] + def task_terminated( self, task: typing.Union[ScenarioAnalysisTask, ScenarioAnalysisTaskApiClient] ): @@ -2388,11 +2393,7 @@ def on_tab_step_changed(self, index: int): self.message_bar.clearWidgets() if index == 3: - analysis_activities = [ - item.activity - for item in self.activity_widget.selected_activity_items() - if item.isEnabled() - ] + analysis_activities = self.selected_activities() is_online_processing = False for activity in analysis_activities: for pathway in activity.pathways: @@ -2438,6 +2439,7 @@ def on_show_metrics_wizard(self): wizard for the scenario analysis report. """ metrics_builder = ActivityMetricsBuilder(self) + metrics_builder.activities = self.selected_activities() if metrics_builder.exec_() == QtWidgets.QDialog.Accepted: pass From bc93f2b19d5b39a3f2922d7b8826cf206ed5946c Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 26 Oct 2024 23:02:38 +0300 Subject: [PATCH 10/47] Fix column metrics. --- .../gui/metrics_builder_dialog.py | 43 ++++-- src/cplus_plugin/gui/metrics_builder_model.py | 133 ++++++++++++++++-- src/cplus_plugin/models/report.py | 25 +++- .../ui/activity_metrics_builder_dialog.ui | 11 +- test/test_gui_item_models.py | 10 +- test/test_model_component_widgets.py | 12 +- 6 files changed, 192 insertions(+), 42 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index bcd5729da..c46f018b8 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -118,6 +118,14 @@ def __init__(self, parent=None, activities=None): self._on_column_expression_changed ) + # Activity metrics page + self.tb_activity_metrics.setModel(self._activity_metric_table_model) + + # Update activities if specified + self._update_activities() + + self.tb_activity_metrics.installEventFilter(self) + # Add the default area column area_metric_column = MetricColumn( "Area", tr("Area (Ha)"), "", auto_calculated=True @@ -125,12 +133,6 @@ def __init__(self, parent=None, activities=None): area_column_item = MetricColumnListItem(area_metric_column) self.add_column_item(area_column_item) - # Activity metrics page - self.tb_activity_metrics.setModel(self._activity_metric_table_model) - - # Update activities if specified - self._update_activities() - @property def column_list_model(self) -> MetricColumnListModel: """Gets the columns list model used in the wizard. @@ -236,7 +238,7 @@ def _update_activities(self): self.clear_activities() for activity in self.activities: - self._activity_metric_table_model.add_activity(activity) + self._activity_metric_table_model.append_activity(activity) def push_column_message( self, @@ -302,8 +304,8 @@ def add_column_item(self, item: MetricColumnListItem): self.select_column(item.row()) # Add column to activity metrics table - self._activity_metric_table_model.add_column(item.model) - self.resize_activity_metrics_table() + self._activity_metric_table_model.append_column(item.model) + self.resize_activity_table_columns() def on_remove_column(self): """Slot raised to remove the selected column.""" @@ -315,7 +317,7 @@ def on_remove_column(self): # Remove corresponding column in activity metrics table self._activity_metric_table_model.remove_column(index) - self.resize_activity_metrics_table() + self.resize_activity_table_columns() def on_move_up_column(self): """Slot raised to move the selected column one level up.""" @@ -541,7 +543,23 @@ def is_columns_page_valid(self) -> bool: return is_valid - def resize_activity_metrics_table(self): + def eventFilter(self, observed_object: QtCore.QObject, event: QtCore.QEvent): + """Captures events sent to specific widgets in the wizard. + + :param observed_object: Object receiving the event. + :type observed_object: QtCore.QObject + + :param event: The specific event being received by the observed object. + :type event: QtCore.QEvent + """ + # Resize activity metric table columns based on the size of the table view. + if observed_object == self.tb_activity_metrics: + if event.type() == QtCore.QEvent.Resize: + self.resize_activity_table_columns() + + return super().eventFilter(observed_object, event) + + def resize_activity_table_columns(self): """Resize column width of activity metrics table for the entire width to be occupied. @@ -554,5 +572,6 @@ def resize_activity_metrics_table(self): width = self.tb_activity_metrics.width() # Make all columns have the same width column_count = self._activity_metric_table_model.columnCount() + column_width = int(width / float(column_count)) for c in range(column_count): - self.tb_activity_metrics.setColumnWidth(c, int(width / float(column_count))) + self.tb_activity_metrics.setColumnWidth(c, column_width) diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 929499e5b..b853ab1e6 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -10,11 +10,16 @@ from ..definitions.constants import ACTIVITY_NAME from ..models.base import Activity -from ..models.report import MetricColumn +from ..models.report import ActivityColumnMetric, MetricColumn, MetricType from ..utils import FileUtils, log, tr +METRIC_COLUMN_LIST_ITEM_TYPE = QtGui.QStandardItem.UserType + 6 +ACTIVITY_NAME_TABLE_ITEM_TYPE = QtGui.QStandardItem.UserType + 7 +ACTIVITY_COLUMN_METRIC_TABLE_ITEM_TYPE = QtGui.QStandardItem.UserType + 8 + + class MetricColumnListItem(QtGui.QStandardItem): """Represents a single carbon layer path.""" @@ -153,6 +158,14 @@ def model(self) -> MetricColumn: """ return self._column + def type(self) -> int: + """Returns the type of the standard item. + + :returns: Type identifier of the item. + :rtype: int + """ + return METRIC_COLUMN_LIST_ITEM_TYPE + class VerticalMoveDirection(IntEnum): """Move an item up or down.""" @@ -349,7 +362,7 @@ def move_column(self, row: int, direction: VerticalMoveDirection) -> int: class ActivityNameTableItem(QtGui.QStandardItem): - """Represents an activity name in the metrics table..""" + """Represents an activity name in the metrics table.""" def __init__(self, activity: Activity): super().__init__() @@ -374,6 +387,72 @@ def activity(self) -> Activity: """ return self._activity + def type(self) -> int: + """Returns the type of the standard item. + + :returns: Type identifier of the item. + :rtype: int + """ + return ACTIVITY_NAME_TABLE_ITEM_TYPE + + +class ActivityColumnMetricItem(QtGui.QStandardItem): + """Represents an activity's metric information for a + specific column. + """ + + def __init__(self, activity_column_metric: ActivityColumnMetric): + super().__init__() + + self._activity_column_metric = activity_column_metric + + self.setEditable(True) + self.setText( + ActivityColumnMetricItem.metric_type_to_str( + activity_column_metric.metric_type + ) + ) + self.setTextAlignment( + self._activity_column_metric.metric_column.alignment + | QtCore.Qt.AlignVCenter + ) + + @staticmethod + def metric_type_to_str(metric_type: MetricType) -> str: + """Returns the corresponding string representation for + the given metric type. + + :param metric_type: Type of metric or expression. + :type metric_type: MetricType + + :returns: The corresponding string representation of + the given metric type. + :rtype: str + """ + if metric_type == MetricType.COLUMN: + return tr("") + elif metric_type == MetricType.CUSTOM: + return tr("") + else: + return tr("") + + @property + def column_metric(self) -> ActivityColumnMetric: + """Gets the underlying activity column metric data model. + + :returns: The underlying activity column metric data model. + :rtype: ActivityColumnMetric + """ + return self._activity_column_metric + + def type(self) -> int: + """Returns the type of the standard item. + + :returns: Type identifier of the item. + :rtype: int + """ + return ACTIVITY_COLUMN_METRIC_TABLE_ITEM_TYPE + class ActivityMetricTableModel(QtGui.QStandardItemModel): """View model for activity metrics in a table.""" @@ -399,7 +478,7 @@ def metric_columns(self) -> typing.List[MetricColumn]: """ return list(self._metric_columns) - def add_column(self, column: MetricColumn): + def append_column(self, column: MetricColumn): """Adds a column to the model based on the information in the metric column. @@ -407,11 +486,27 @@ def add_column(self, column: MetricColumn): for defining the new column. :type column: MetricColumn """ - headers = [ - self.headerData(c, QtCore.Qt.Horizontal) for c in range(self.columnCount()) - ] - headers.append(column.header) - self.setHorizontalHeaderLabels(headers) + column_items = [] + + # Update rows based on the selected activities + for activity in self.activities: + activity_column_metric = ActivityColumnMetric( + activity, + column, + MetricType.COLUMN if column.expression else MetricType.NOT_SET, + column.expression if column.expression else "", + ) + item = ActivityColumnMetricItem(activity_column_metric) + column_items.append(item) + + self.appendColumn(column_items) + self.setHeaderData( + self.columnCount() - 1, + QtCore.Qt.Horizontal, + column.header, + QtCore.Qt.DisplayRole, + ) + self._metric_columns.append(column) def remove_column(self, index: int) -> bool: @@ -459,7 +554,7 @@ def update_column_properties(self, index: int, column: MetricColumn): ) self._metric_columns[index] = column - def add_activity(self, activity: Activity) -> bool: + def append_activity(self, activity: Activity) -> bool: """Adds an activity row in the activity metrics table. :param activity: Activity to be added. @@ -476,8 +571,26 @@ def add_activity(self, activity: Activity) -> bool: if len(matching_activities) > 0: return False + row_items = [] + activity_item = ActivityNameTableItem(activity) - self.appendRow(activity_item) + row_items.append(activity_item) + + log("Checking for metric columns...") + + # Set corresponding activity column metric items + for mc in self._metric_columns: + log("There is an existing column:") + log(mc.header) + activity_column_metric = ActivityColumnMetric( + activity, + mc, + MetricType.COLUMN if mc.expression else MetricType.NOT_SET, + mc.expression if mc.expression else "", + ) + row_items.append(ActivityColumnMetricItem(activity_column_metric)) + + self.appendRow(row_items) return True diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index 1ad9896be..306a42b5f 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -135,11 +135,12 @@ def to_qgs_column(self) -> QgsLayoutTableColumn: return layout_column -class ExpressionType(IntEnum): - """Type of expression.""" +class MetricType(IntEnum): + """Type of metric or expression.""" COLUMN = 0 CUSTOM = 1 + NOT_SET = 2 @dataclasses.dataclass @@ -148,8 +149,18 @@ class ActivityColumnMetric: applied in each activity's column. """ - column_name: str # Assuming each metric column will have a unique name - activity_id: str - expression: str - expression_type: ExpressionType = ExpressionType.COLUMN - activity: Activity = None + activity: Activity + metric_column: MetricColumn + metric_type: MetricType = MetricType.NOT_SET + expression: str = "" + + +@dataclasses.dataclass +class MetricConfiguration: + """Container for metric column and + activity column metric data models. + """ + + activities: typing.List[Activity] + metric_columns: typing.List[MetricColumn] + activity_metrics: typing.List[typing.List[ActivityColumnMetric]] diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 40a93c0a0..6e2c4d0cc 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -310,7 +310,7 @@ Activity Metric - Customize the metric for each selected activity. Double click a cell to edit the metric. + Customize the metric for each selected activity. 2 @@ -331,9 +331,16 @@ false - + + + + + Double click a column cell to edit its metric + + + diff --git a/test/test_gui_item_models.py b/test/test_gui_item_models.py index 8dd07dc5d..780f6cadc 100644 --- a/test/test_gui_item_models.py +++ b/test/test_gui_item_models.py @@ -75,20 +75,20 @@ def setUp(self): def test_add_implementation_model(self): """Assert an activity can be added.""" activity_item_model = ActivityItemModel(PARENT) - result = activity_item_model.add_activity(get_activity()) + result = activity_item_model.append_activity(get_activity()) self.assertTrue(result) def test_model_has_items(self): """Assert the item model actually contains items.""" activity_item_model = ActivityItemModel(PARENT) - _ = activity_item_model.add_activity(get_activity()) + _ = activity_item_model.append_activity(get_activity()) activities = activity_item_model.activities() self.assertEqual(len(activities), 1) def test_remove_activity(self): """Assert an activity can be removed.""" activity_item_model = ActivityItemModel(PARENT) - _ = activity_item_model.add_activity(get_activity()) + _ = activity_item_model.append_activity(get_activity()) result = activity_item_model.remove_activity(ACTIVITY_UUID_STR) self.assertTrue(result) @@ -97,7 +97,7 @@ def test_add_activity_with_layer(self): activity_item_model = ActivityItemModel(PARENT) activity = get_activity() layer = get_test_layer() - result = activity_item_model.add_activity(activity, layer) + result = activity_item_model.append_activity(activity, layer) self.assertTrue(result) def test_remove_activity_with_layer(self): @@ -105,7 +105,7 @@ def test_remove_activity_with_layer(self): activity_item_model = ActivityItemModel(PARENT) activity = get_activity() layer = get_test_layer() - _ = activity_item_model.add_activity(activity, layer) + _ = activity_item_model.append_activity(activity, layer) result = activity_item_model.remove_activity(ACTIVITY_UUID_STR) self.assertTrue(result) diff --git a/test/test_model_component_widgets.py b/test/test_model_component_widgets.py index e66f3181a..28b8e569e 100644 --- a/test/test_model_component_widgets.py +++ b/test/test_model_component_widgets.py @@ -64,7 +64,7 @@ def test_add_activity(self): """ activity = get_activity() activity_widget = ActivityComponentWidget(PARENT) - result = activity_widget.add_activity(activity) + result = activity_widget.append_activity(activity) self.assertTrue(result) def test_add_activity_with_layer(self): @@ -74,14 +74,14 @@ def test_add_activity_with_layer(self): activity = get_activity() layer = get_test_layer() activity_widget = ActivityComponentWidget(PARENT) - result = activity_widget.add_activity(activity, layer) + result = activity_widget.append_activity(activity, layer) self.assertTrue(result) def test_get_activities(self): """Assert number of activity objects retrieved.""" activity = get_activity() activity_widget = ActivityComponentWidget(PARENT) - _ = activity_widget.add_activity(activity) + _ = activity_widget.append_activity(activity) activities = activity_widget.activities() self.assertEqual(len(activities), 1) @@ -89,7 +89,7 @@ def test_clear_activities(self): """Assert activities objects can be cleared.""" activity = get_activity() activity_widget = ActivityComponentWidget(PARENT) - _ = activity_widget.add_activity(activity) + _ = activity_widget.append_activity(activity) activity_widget.clear() activities = activity_widget.activities() self.assertEqual(len(activities), 0) @@ -101,7 +101,7 @@ def test_can_add_ncs_pathway_items(self): activity = get_activity() activity.clear_layer() activity_widget = ActivityComponentWidget(PARENT) - _ = activity_widget.add_activity(activity) + _ = activity_widget.append_activity(activity) # Select the added activity. sel_model = activity_widget.selection_model @@ -121,7 +121,7 @@ def test_cannot_add_ncs_pathway_items(self): """ activity = get_activity() activity_widget = ActivityComponentWidget(PARENT) - _ = activity_widget.add_activity(activity) + _ = activity_widget.append_activity(activity) # Select the added activity. sel_model = activity_widget.selection_model From 55cbf3358700aa3539a701f3387792ce78e68279 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Tue, 29 Oct 2024 09:30:01 +0300 Subject: [PATCH 11/47] Enable expression builder in activity columns. --- src/cplus_plugin/definitions/defaults.py | 3 + .../gui/metrics_builder_dialog.py | 174 ++++++++++++++++-- src/cplus_plugin/gui/metrics_builder_model.py | 111 +++++++++-- 3 files changed, 253 insertions(+), 35 deletions(-) diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index bd54d7867..22b7e3646 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -231,3 +231,6 @@ DEFAULT_BASE_COMPARISON_REPORT_NAME = "Scenario Comparison Report" MAXIMUM_COMPARISON_REPORTS = 10 + +# Default metric calculations +METRIC_ACTIVITY_AREA = "@activity_area" diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index c46f018b8..599d56fb9 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -7,15 +7,8 @@ import re import typing -from qgis.core import ( - Qgis, - QgsColorRamp, - QgsFillSymbolLayer, - QgsGradientColorRamp, - QgsMapLayerProxyModel, - QgsRasterLayer, -) -from qgis.gui import QgsGui, QgsMessageBar +from qgis.core import Qgis, QgsVectorLayer +from qgis.gui import QgsExpressionBuilderDialog, QgsGui, QgsMessageBar from qgis.PyQt import QtCore, QtGui, QtWidgets @@ -23,21 +16,159 @@ from ..conf import Settings, settings_manager -from ..definitions.defaults import ICON_PATH, USER_DOCUMENTATION_SITE +from ..definitions.defaults import METRIC_ACTIVITY_AREA, USER_DOCUMENTATION_SITE from .metrics_builder_model import ( + ActivityColumnMetricItem, ActivityMetricTableModel, + COLUMN_METRIC_STR, + CUSTOM_METRIC_STR, MetricColumnListItem, MetricColumnListModel, ) from ..models.base import Activity -from ..models.report import MetricColumn -from ..utils import FileUtils, log, generate_random_color, open_documentation, tr +from ..models.report import MetricColumn, MetricType +from ..utils import FileUtils, log, open_documentation, tr WidgetUi, _ = loadUiType( os.path.join(os.path.dirname(__file__), "../ui/activity_metrics_builder_dialog.ui") ) +class MetricItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Delegate for enabling the user to choose the type of metric for a + particular activity column. + """ + + def createEditor( + self, + parent: QtWidgets.QWidget, + option: QtWidgets.QStyleOptionViewItem, + idx: QtCore.QModelIndex, + ) -> QtWidgets.QLineEdit: + """Creates a combobox for choosing the metric type. + + :param parent: Parent widget. + :type parent: QtWidgets.QWidget + + :param option: Options for drawing the widget in the view. + :type option: QtWidgets.QStyleOptionViewItem + + :param idx: Location of the request in the data model. + :type idx: QtCore.QModelIndex + + :returns: The editor widget. + :rtype: QtWidgets.QLineEdit + """ + metric_combobox = QtWidgets.QComboBox(parent) + metric_combobox.setFrame(False) + metric_combobox.addItem(tr(COLUMN_METRIC_STR), MetricType.COLUMN) + metric_combobox.addItem(tr(CUSTOM_METRIC_STR), MetricType.CUSTOM) + metric_combobox.currentIndexChanged.connect(self.on_metric_type_changed) + + return metric_combobox + + def setEditorData(self, widget: QtWidgets.QWidget, idx: QtCore.QModelIndex): + """Sets the data to be displayed and edited by the editor. + + :param widget: Editor widget. + :type widget: QtWidgets.QWidget + + :param idx: Location in the data model. + :type idx: QtCore.QModelIndex + """ + select_index = -1 + + item = idx.model().itemFromIndex(idx) + if item is None or not isinstance(item, ActivityColumnMetricItem): + return + + current_metric_type = item.metric_type + if current_metric_type == MetricType.COLUMN: + select_index = widget.findData(MetricType.COLUMN) + elif current_metric_type == MetricType.CUSTOM: + select_index = widget.findData(MetricType.CUSTOM) + + if select_index != -1: + # We are temporarily blocking the index changed slot + # so that the expression dialog will not be shown if + # the metric type is custom. + widget.blockSignals(True) + widget.setCurrentIndex(select_index) + widget.blockSignals(False) + + def on_metric_type_changed(self, index: int): + """Slot raised when the metric type has changed. + + We use this to load the expression builder if a + custom metric is selected. + + :param index: Index of the current selection. + :type index: int + """ + if index == -1: + return + + editor = self.sender() + metric_type = editor.itemData(index) + if metric_type != MetricType.CUSTOM: + return + + expression_builder = QgsExpressionBuilderDialog(None, "", editor, "cplus") + expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) + if expression_builder.exec_() == QtWidgets.QDialog.Accepted: + pass + + self.commitData.emit(editor) + self.closeEditor.emit(editor, QtWidgets.QAbstractItemDelegate.NoHint) + + def setModelData( + self, + widget: QtWidgets.QWidget, + model: QtCore.QAbstractItemModel, + idx: QtCore.QModelIndex, + ): + """Gets data from the editor widget and stores it in the specified + model at the item index. + + :param widget: Editor widget. + :type widget: QtWidgets.QWidget + + :param model: Model to store the editor data in. + :type model: QtCore.QAbstractItemModel + + :param idx: Location in the data model. + :type idx: QtCore.QModelIndex + """ + metric_type = widget.itemData(widget.currentIndex()) + item = idx.model().itemFromIndex(idx) + if item is None or not isinstance(item, ActivityColumnMetricItem): + return + + item.update_metric_type(metric_type) + + def updateEditorGeometry( + self, + widget: QtWidgets.QWidget, + option: QtWidgets.QStyleOptionViewItem, + idx: QtCore.QModelIndex, + ): + """Updates the geometry of the editor for the item with the given index, + according to the rectangle specified in the option. + + :param widget: Widget whose geometry will be updated. + :type widget: QtWidgets.QWidget + + :param option: Option containing the rectangle for + updating the widget. + :type option: QtWidgets.QStyleOptionViewItem + + :param idx: Location of the widget in the data model. + :type idx: QtCore.QModelIndex + """ + widget.setGeometry(option.rect) + + class ActivityMetricsBuilder(QtWidgets.QWizard, WidgetUi): """Wizard for customizing custom activity metrics table.""" @@ -128,7 +259,7 @@ def __init__(self, parent=None, activities=None): # Add the default area column area_metric_column = MetricColumn( - "Area", tr("Area (Ha)"), "", auto_calculated=True + "Area", tr("Area (Ha)"), METRIC_ACTIVITY_AREA, auto_calculated=True ) area_column_item = MetricColumnListItem(area_metric_column) self.add_column_item(area_column_item) @@ -186,24 +317,22 @@ def on_page_id_changed(self, page_id: int): ) self.setWindowTitle(window_title) - def initializePage(self, page_id: int): - """Initialize wizard page prior to loading. - - :param page_id: ID of the wizard page. - :type page_id: int - """ # Activity metrics page if page_id == 2: # If expression is not specified for at # least one column then enable the groupbox. + group_box_checked = False + self.gb_custom_activity_metric.setChecked(group_box_checked) for item in self._column_list_model.column_items: if ( not item.expression and not self.gb_custom_activity_metric.isChecked() ): - self.gb_custom_activity_metric.setChecked(True) + group_box_checked = True break + self.gb_custom_activity_metric.setChecked(group_box_checked) + def validateCurrentPage(self) -> bool: """Validates the current page. @@ -307,6 +436,11 @@ def add_column_item(self, item: MetricColumnListItem): self._activity_metric_table_model.append_column(item.model) self.resize_activity_table_columns() + if not item.model.auto_calculated: + self.tb_activity_metrics.setItemDelegateForColumn( + item.row() + 1, MetricItemDelegate() + ) + def on_remove_column(self): """Slot raised to remove the selected column.""" selected_items = self.selected_column_items() diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index b853ab1e6..48170153e 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -19,6 +19,9 @@ ACTIVITY_NAME_TABLE_ITEM_TYPE = QtGui.QStandardItem.UserType + 7 ACTIVITY_COLUMN_METRIC_TABLE_ITEM_TYPE = QtGui.QStandardItem.UserType + 8 +COLUMN_METRIC_STR = "" +CUSTOM_METRIC_STR = "" + class MetricColumnListItem(QtGui.QStandardItem): """Represents a single carbon layer path.""" @@ -406,17 +409,21 @@ def __init__(self, activity_column_metric: ActivityColumnMetric): self._activity_column_metric = activity_column_metric - self.setEditable(True) - self.setText( - ActivityColumnMetricItem.metric_type_to_str( - activity_column_metric.metric_type - ) - ) + if activity_column_metric.metric_column.auto_calculated: + self.setEditable(False) + else: + self.setEditable(True) + + self._update_display_text() self.setTextAlignment( self._activity_column_metric.metric_column.alignment | QtCore.Qt.AlignVCenter ) + # self.setData(self._activity_column_metric.metric_type, QtCore.Qt.EditRole) + + self._update_tool_tip() + @staticmethod def metric_type_to_str(metric_type: MetricType) -> str: """Returns the corresponding string representation for @@ -430,9 +437,9 @@ def metric_type_to_str(metric_type: MetricType) -> str: :rtype: str """ if metric_type == MetricType.COLUMN: - return tr("") + return tr(COLUMN_METRIC_STR) elif metric_type == MetricType.CUSTOM: - return tr("") + return tr(CUSTOM_METRIC_STR) else: return tr("") @@ -445,6 +452,77 @@ def column_metric(self) -> ActivityColumnMetric: """ return self._activity_column_metric + @property + def metric_type(self) -> MetricType: + """Gets the metric type of the underlying data model. + + :returns: The metric type of the underlying data model. + :rtype: MetricType + """ + return self._activity_column_metric.metric_type + + def update_metric_type(self, metric_type: MetricType, expression: str = ""): + """Updates the metric type of the underlying metric model. + + :param metric_type: Metric type to be used by the model. + :type metric_type: MetricType + + :param expression: Expression for the given metric type. + Default is an empty string. + :type expression: str + """ + if self._activity_column_metric.metric_type == metric_type: + return + + self._activity_column_metric.metric_type = metric_type + self._activity_column_metric.expression = expression + + self._update_display_text() + self._update_tool_tip() + + def update_metric_model(self, model: MetricColumn): + """Updates the underlying metric model. + + :param model: Metric column containing updated properties. + :type model: MetricColumn + """ + if ( + self._activity_column_metric.metric_type == MetricType.NOT_SET + and model.expression + ): + self._activity_column_metric.metric_type = MetricType.COLUMN + self._activity_column_metric.expression = model.expression + elif ( + self._activity_column_metric.metric_type == MetricType.COLUMN + and not model.expression + ): + self._activity_column_metric.metric_type = MetricType.NOT_SET + self._activity_column_metric.expression = "" + + self._activity_column_metric.metric_column = model + + self._update_display_text() + self._update_tool_tip() + + def _update_tool_tip(self): + """Updates the tooltip to show the expression.""" + if self._activity_column_metric.metric_type == MetricType.NOT_SET: + self.setToolTip("") + else: + self.setToolTip(self._activity_column_metric.expression) + + def _update_display_text(self): + """Updates the display text of the item. + + This should be called when there are any + changes in the activity column metric model. + """ + self.setText( + ActivityColumnMetricItem.metric_type_to_str( + self._activity_column_metric.metric_type + ) + ) + def type(self) -> int: """Returns the type of the standard item. @@ -527,7 +605,6 @@ def remove_column(self, index: int) -> bool: return False model_index = index + 1 - status = self.removeColumns(model_index, 1) del self._metric_columns[index] @@ -544,16 +621,24 @@ def update_column_properties(self, index: int, column: MetricColumn): :param column: Updated column metric object. :type column: MetricColumn """ - if index == -1: + model_index = index + 1 + if model_index == 0 or model_index >= self.columnCount(): return False # Update header - model_index = index + 1 self.setHeaderData( model_index, QtCore.Qt.Horizontal, column.header, QtCore.Qt.DisplayRole ) self._metric_columns[index] = column + # Update corresponding column metric items in the given column + for r in range(self.rowCount()): + column_metric_item = self.item(r, model_index) + if column_metric_item is None: + continue + + column_metric_item.update_metric_model(column) + def append_activity(self, activity: Activity) -> bool: """Adds an activity row in the activity metrics table. @@ -576,12 +661,8 @@ def append_activity(self, activity: Activity) -> bool: activity_item = ActivityNameTableItem(activity) row_items.append(activity_item) - log("Checking for metric columns...") - # Set corresponding activity column metric items for mc in self._metric_columns: - log("There is an existing column:") - log(mc.header) activity_column_metric = ActivityColumnMetric( activity, mc, From f8b0a4d55db14a24dccef879a513274abf02d847 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 30 Oct 2024 19:47:10 +0300 Subject: [PATCH 12/47] Fix crash due to column metric delegate. --- .../gui/metrics_builder_dialog.py | 100 ++++++++++++++-- src/cplus_plugin/gui/metrics_builder_model.py | 108 ++++++++++++++++++ src/cplus_plugin/models/report.py | 18 +++ .../ui/activity_metrics_builder_dialog.ui | 6 +- 4 files changed, 224 insertions(+), 8 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 599d56fb9..da2674836 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -34,12 +34,14 @@ ) -class MetricItemDelegate(QtWidgets.QStyledItemDelegate): +class ColumnMetricItemDelegate(QtWidgets.QStyledItemDelegate): """ Delegate for enabling the user to choose the type of metric for a particular activity column. """ + INDEX_PROPERTY_NAME = "delegate_index" + def createEditor( self, parent: QtWidgets.QWidget, @@ -62,6 +64,7 @@ def createEditor( """ metric_combobox = QtWidgets.QComboBox(parent) metric_combobox.setFrame(False) + metric_combobox.setProperty(self.INDEX_PROPERTY_NAME, idx) metric_combobox.addItem(tr(COLUMN_METRIC_STR), MetricType.COLUMN) metric_combobox.addItem(tr(CUSTOM_METRIC_STR), MetricType.CUSTOM) metric_combobox.currentIndexChanged.connect(self.on_metric_type_changed) @@ -114,10 +117,24 @@ def on_metric_type_changed(self, index: int): if metric_type != MetricType.CUSTOM: return - expression_builder = QgsExpressionBuilderDialog(None, "", editor, "cplus") + model_index = editor.property(self.INDEX_PROPERTY_NAME) + if not model_index.isValid(): + log(tr("Invalid index for activity column metric.")) + return + + activity_column_metric_item = model_index.model().itemFromIndex(model_index) + if activity_column_metric_item is None: + log(tr("Activity column metric could not be found.")) + return + + expression_builder = QgsExpressionBuilderDialog( + None, activity_column_metric_item.expression, editor, "CPLUS" + ) expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) if expression_builder.exec_() == QtWidgets.QDialog.Accepted: - pass + activity_column_metric_item.update_metric_type( + MetricType.CUSTOM, expression_builder.expressionText() + ) self.commitData.emit(editor) self.closeEditor.emit(editor, QtWidgets.QAbstractItemDelegate.NoHint) @@ -145,7 +162,14 @@ def setModelData( if item is None or not isinstance(item, ActivityColumnMetricItem): return - item.update_metric_type(metric_type) + # Inherit the column expression if defined + expression = "" + if metric_type == MetricType.COLUMN: + metric_column = model.metric_column(idx.column() - 1) + if metric_column is not None: + expression = metric_column.expression + + item.update_metric_type(metric_type, expression) def updateEditorGeometry( self, @@ -186,6 +210,9 @@ def __init__(self, parent=None, activities=None): self._column_message_bar = QgsMessageBar() self.vl_column_notification.addWidget(self._column_message_bar) + self._activity_metric_message_bar = QgsMessageBar() + self.vl_metric_notification.addWidget(self._activity_metric_message_bar) + self._column_list_model = MetricColumnListModel() self._activity_metric_table_model = ActivityMetricTableModel() @@ -343,6 +370,9 @@ def validateCurrentPage(self) -> bool: if self.currentId() == 1: return self.is_columns_page_valid() + elif self.currentId() == 2: + return self.is_activity_metrics_page_valid() + return True def on_help_requested(self): @@ -393,6 +423,30 @@ def push_column_message( self._column_message_bar.pushMessage(message, level, 5) + def push_activity_metric_message( + self, + message: str, + level: Qgis.MessageLevel = Qgis.MessageLevel.Warning, + clear_first: bool = False, + ): + """Push a message to the notification bar in the + activity metric wizard page. + + :param message: Message to the show in the notification bar. + :type message: str + + :param level: Severity of the message. Warning is the default. + :type level: Qgis.MessageLevel + + :param clear_first: Clear any current messages in the notification + bar, default is False. + :type clear_first: bool + """ + if clear_first: + self._activity_metric_message_bar.clearWidgets() + + self._activity_metric_message_bar.pushMessage(message, level, 5) + def on_add_column(self): """Slot raised to add a new column.""" label_text = ( @@ -438,7 +492,8 @@ def add_column_item(self, item: MetricColumnListItem): if not item.model.auto_calculated: self.tb_activity_metrics.setItemDelegateForColumn( - item.row() + 1, MetricItemDelegate() + item.row() + 1, + ColumnMetricItemDelegate(self.tb_activity_metrics) ) def on_remove_column(self): @@ -471,7 +526,22 @@ def on_move_up_column(self): # Move corresponding column in the activity metrics table. # We have normalized it to reflect the position in the # metrics table. - self._activity_metric_table_model.move_column_left(current_row + 1) + reference_index = current_row + 1 + reference_delegate = self.tb_activity_metrics.itemDelegateForColumn( + reference_index + ) + adjacent_delegate = self.tb_activity_metrics.itemDelegateForColumn( + reference_index - 1 + ) + new_index = self._activity_metric_table_model.move_column_left(reference_index) + if new_index != -1: + # Also adjust the delegates + self.tb_activity_metrics.setItemDelegateForColumn( + new_index, reference_delegate + ) + self.tb_activity_metrics.setItemDelegateForColumn( + new_index + 1, adjacent_delegate + ) def on_move_down_column(self): """Slot raised to move the selected column one level down.""" @@ -640,7 +710,7 @@ def save_column_properties(self): current_column.alignment = self.cbo_column_alignment.itemData( self.cbo_column_alignment.currentIndex() ) - current_column.expression = self.cbo_column_expression.currentText() + current_column.expression = self.cbo_column_expression.expression() # Update column properties in activity metrics table self._activity_metric_table_model.update_column_properties( @@ -677,6 +747,22 @@ def is_columns_page_valid(self) -> bool: return is_valid + def is_activity_metrics_page_valid(self) -> bool: + """Validates the activity metrics page. + + :returns: True if the activity metrics page is valid, + else False. + :rtype: bool + """ + self._activity_metric_message_bar.clearWidgets() + + is_valid = self._activity_metric_table_model.validate(True) + if not is_valid: + msg = tr("The highlighted activity metric items are invalid.") + self.push_activity_metric_message(msg) + + return is_valid + def eventFilter(self, observed_object: QtCore.QObject, event: QtCore.QEvent): """Captures events sent to specific widgets in the wizard. diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 48170153e..69019007c 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -452,6 +452,15 @@ def column_metric(self) -> ActivityColumnMetric: """ return self._activity_column_metric + @property + def expression(self) -> str: + """Gets the item's expression. + + :returns: Item's expression. + :rtype: str + """ + return self._activity_column_metric.expression + @property def metric_type(self) -> MetricType: """Gets the metric type of the underlying data model. @@ -531,6 +540,39 @@ def type(self) -> int: """ return ACTIVITY_COLUMN_METRIC_TABLE_ITEM_TYPE + def is_valid(self) -> bool: + """Checks if the activity column metric is valid. + + :returns: True if the activity column metric is + valid else False. + :rtype: bool + """ + return self._activity_column_metric.is_valid() + + def highlight_invalid(self, show: bool): + """Highlights the item with a red background to indicate + that the activity column metric is invalid. + + :param show: True to highlight the item else False to + disable. A call to highlight will first verify that the + data model is valid. If it is valid then the item will + not be highlighted. + :type show: bool + """ + if self.is_valid() and show: + return + + background = self.background() + + if show: + background.setColor(QtGui.QColor("#ffaeae")) + background.setStyle(QtCore.Qt.SolidPattern) + else: + background.setColor(QtCore.Qt.white) + background.setStyle(QtCore.Qt.NoBrush) + + self.setBackground(background) + class ActivityMetricTableModel(QtGui.QStandardItemModel): """View model for activity metrics in a table.""" @@ -546,6 +588,14 @@ def __init__(self, parent=None, columns: typing.List[MetricColumn] = None): if columns is not None: self._metric_columns = columns + # Timer for intermittently showing invalid items + self._validation_highlight_timer = QtCore.QTimer() + self._validation_highlight_timer.setSingleShot(True) + self._validation_highlight_timer.setInterval(5000) + self._validation_highlight_timer.timeout.connect( + self._on_validation_highlight_timeout + ) + @property def metric_columns(self) -> typing.List[MetricColumn]: """Gets the metric columns used in the model to @@ -611,6 +661,21 @@ def remove_column(self, index: int) -> bool: return status + def metric_column(self, index: int) -> typing.Optional[MetricColumn]: + """Gets the metric column at the given location. + + :param index: Index of the metric column. + :type index: int + + :returns: The metric column at the given index else + None if the index is invalid. + :rtype: typing.Optional[MetricColumn] + """ + if index < 0 or index > len(self._metric_columns) - 1: + return None + + return self._metric_columns[index] + def update_column_properties(self, index: int, column: MetricColumn): """Updates the properties of an underlying metric column in the model. @@ -637,6 +702,11 @@ def update_column_properties(self, index: int, column: MetricColumn): if column_metric_item is None: continue + # We ignore custom metrics since we do not want to fiddle + # with what the user has already specified. + if column_metric_item.metric_type == MetricType.CUSTOM: + continue + column_metric_item.update_metric_model(column) def append_activity(self, activity: Activity) -> bool: @@ -746,3 +816,41 @@ def move_column_right(self, current_index: int) -> int: :rtype: int """ return self.move_column(current_index, HorizontalMoveDirection.RIGHT) + + def validate(self, highlight_invalid: bool) -> bool: + """Validate the items in the model. + + :param highlight_invalid: True to highlight invalid + activity metric column items, else False to ignore + highlighting invalid items. If True, the invalid items + will be highlighted for a default period of 3000ms. + :type highlight_invalid: bool + + :returns: True if the items are valid else False. + :rtype: bool + """ + is_valid = True + + if self._validation_highlight_timer.isActive(): + self._validation_highlight_timer.stop() + + self._validation_highlight_timer.start() + + for r in range(self.rowCount()): + for c in range(1, self.columnCount()): + item = self.item(r, c) + if not item.is_valid(): + if is_valid: + is_valid = False + if highlight_invalid: + item.highlight_invalid(True) + + return is_valid + + def _on_validation_highlight_timeout(self): + """Revert invalid items to a normal background.""" + for r in range(self.rowCount()): + for c in range(1, self.columnCount()): + item = self.item(r, c) + if not item.is_valid(): + item.highlight_invalid(False) diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index 306a42b5f..076fc3689 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -154,6 +154,24 @@ class ActivityColumnMetric: metric_type: MetricType = MetricType.NOT_SET expression: str = "" + def is_valid(self) -> bool: + """Checks if the activity column metric is valid. + + :returns: True if the activity column metric is + valid else False. + :rtype: bool + """ + if self.activity is None or self.metric_column is None: + return False + + if self.metric_type == MetricType.NOT_SET: + return False + + if not self.expression: + return False + + return True + @dataclasses.dataclass class MetricConfiguration: diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 6e2c4d0cc..cd04219ae 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -332,7 +332,11 @@ - + + + QAbstractItemView::SingleSelection + + From 4d9ca13cc02a7b678b03bb09eae63d6f1283d613 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 30 Oct 2024 23:14:26 +0300 Subject: [PATCH 13/47] Incorporate summary page. --- .../gui/metrics_builder_dialog.py | 37 +++++--- src/cplus_plugin/gui/metrics_builder_model.py | 88 +++++++++++++++++-- src/cplus_plugin/models/report.py | 2 +- .../ui/activity_metrics_builder_dialog.ui | 5 +- 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index da2674836..2927fba4b 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -19,9 +19,10 @@ from ..definitions.defaults import METRIC_ACTIVITY_AREA, USER_DOCUMENTATION_SITE from .metrics_builder_model import ( ActivityColumnMetricItem, + ActivityColumnSummaryTreeModel, ActivityMetricTableModel, COLUMN_METRIC_STR, - CUSTOM_METRIC_STR, + CELL_METRIC_STR, MetricColumnListItem, MetricColumnListModel, ) @@ -66,7 +67,7 @@ def createEditor( metric_combobox.setFrame(False) metric_combobox.setProperty(self.INDEX_PROPERTY_NAME, idx) metric_combobox.addItem(tr(COLUMN_METRIC_STR), MetricType.COLUMN) - metric_combobox.addItem(tr(CUSTOM_METRIC_STR), MetricType.CUSTOM) + metric_combobox.addItem(tr(CELL_METRIC_STR), MetricType.CELL) metric_combobox.currentIndexChanged.connect(self.on_metric_type_changed) return metric_combobox @@ -89,13 +90,13 @@ def setEditorData(self, widget: QtWidgets.QWidget, idx: QtCore.QModelIndex): current_metric_type = item.metric_type if current_metric_type == MetricType.COLUMN: select_index = widget.findData(MetricType.COLUMN) - elif current_metric_type == MetricType.CUSTOM: - select_index = widget.findData(MetricType.CUSTOM) + elif current_metric_type == MetricType.CELL: + select_index = widget.findData(MetricType.CELL) if select_index != -1: # We are temporarily blocking the index changed slot # so that the expression dialog will not be shown if - # the metric type is custom. + # the metric type is cell-based. widget.blockSignals(True) widget.setCurrentIndex(select_index) widget.blockSignals(False) @@ -104,7 +105,7 @@ def on_metric_type_changed(self, index: int): """Slot raised when the metric type has changed. We use this to load the expression builder if a - custom metric is selected. + cell metric is selected. :param index: Index of the current selection. :type index: int @@ -114,7 +115,7 @@ def on_metric_type_changed(self, index: int): editor = self.sender() metric_type = editor.itemData(index) - if metric_type != MetricType.CUSTOM: + if metric_type != MetricType.CELL: return model_index = editor.property(self.INDEX_PROPERTY_NAME) @@ -133,7 +134,7 @@ def on_metric_type_changed(self, index: int): expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) if expression_builder.exec_() == QtWidgets.QDialog.Accepted: activity_column_metric_item.update_metric_type( - MetricType.CUSTOM, expression_builder.expressionText() + MetricType.CELL, expression_builder.expressionText() ) self.commitData.emit(editor) @@ -214,8 +215,8 @@ def __init__(self, parent=None, activities=None): self.vl_metric_notification.addWidget(self._activity_metric_message_bar) self._column_list_model = MetricColumnListModel() - self._activity_metric_table_model = ActivityMetricTableModel() + self._summary_model = ActivityColumnSummaryTreeModel() # Initialize wizard ci_icon = FileUtils.get_icon("cplus_logo.svg") @@ -284,6 +285,9 @@ def __init__(self, parent=None, activities=None): self.tb_activity_metrics.installEventFilter(self) + # Final summary page + self.tv_summary.setModel(self._summary_model) + # Add the default area column area_metric_column = MetricColumn( "Area", tr("Area (Ha)"), METRIC_ACTIVITY_AREA, auto_calculated=True @@ -360,6 +364,10 @@ def on_page_id_changed(self, page_id: int): self.gb_custom_activity_metric.setChecked(group_box_checked) + # Final summary page + elif page_id == 3: + self.load_summary_details() + def validateCurrentPage(self) -> bool: """Validates the current page. @@ -492,8 +500,7 @@ def add_column_item(self, item: MetricColumnListItem): if not item.model.auto_calculated: self.tb_activity_metrics.setItemDelegateForColumn( - item.row() + 1, - ColumnMetricItemDelegate(self.tb_activity_metrics) + item.row() + 1, ColumnMetricItemDelegate(self.tb_activity_metrics) ) def on_remove_column(self): @@ -758,7 +765,7 @@ def is_activity_metrics_page_valid(self) -> bool: is_valid = self._activity_metric_table_model.validate(True) if not is_valid: - msg = tr("The highlighted activity metric items are invalid.") + msg = tr("The metrics for the highlighted items are undefined.") self.push_activity_metric_message(msg) return is_valid @@ -795,3 +802,9 @@ def resize_activity_table_columns(self): column_width = int(width / float(column_count)) for c in range(column_count): self.tb_activity_metrics.setColumnWidth(c, column_width) + + def load_summary_details(self): + """Load items summarizing the metric configuration.""" + activity_column_metric_models = self._activity_metric_table_model.models + self._summary_model.set_summary_models(activity_column_metric_models) + self.tv_summary.expandAll() diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 69019007c..83b34e236 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -20,7 +20,7 @@ ACTIVITY_COLUMN_METRIC_TABLE_ITEM_TYPE = QtGui.QStandardItem.UserType + 8 COLUMN_METRIC_STR = "" -CUSTOM_METRIC_STR = "" +CELL_METRIC_STR = "" class MetricColumnListItem(QtGui.QStandardItem): @@ -438,13 +438,13 @@ def metric_type_to_str(metric_type: MetricType) -> str: """ if metric_type == MetricType.COLUMN: return tr(COLUMN_METRIC_STR) - elif metric_type == MetricType.CUSTOM: - return tr(CUSTOM_METRIC_STR) + elif metric_type == MetricType.CELL: + return tr(CELL_METRIC_STR) else: return tr("") @property - def column_metric(self) -> ActivityColumnMetric: + def model(self) -> ActivityColumnMetric: """Gets the underlying activity column metric data model. :returns: The underlying activity column metric data model. @@ -702,9 +702,9 @@ def update_column_properties(self, index: int, column: MetricColumn): if column_metric_item is None: continue - # We ignore custom metrics since we do not want to fiddle - # with what the user has already specified. - if column_metric_item.metric_type == MetricType.CUSTOM: + # We ignore cell metrics since we do not want to change + # what the user has already specified. + if column_metric_item.metric_type == MetricType.CELL: continue column_metric_item.update_metric_model(column) @@ -854,3 +854,77 @@ def _on_validation_highlight_timeout(self): item = self.item(r, c) if not item.is_valid(): item.highlight_invalid(False) + + @property + def models(self) -> typing.List[typing.List[ActivityColumnMetric]]: + """Gets the mapping of activity column metric data models. + + :returns: A nested list of activity column metrics in the same order + that they are stored in the model. + :rtype: typing.List[typing.List[ActivityColumnMetric]] + """ + activity_metric_models = [] + + for r in range(self.rowCount()): + row_models = [] + for c in range(1, self.columnCount()): + item = self.item(r, c) + row_models.append(item.model) + + activity_metric_models.append(row_models) + + return activity_metric_models + + +class ActivityColumnSummaryItem(QtGui.QStandardItem): + """Provides an item for displaying the configuration of metrics + for the activity by listing the specific metrics for each + column. + """ + + def __init__(self, activity_column_metrics: typing.List[ActivityColumnMetric]): + super().__init__() + + self._activity_column_metrics = activity_column_metrics + + self._reference_activity = None + for acm in self._activity_column_metrics: + if self._reference_activity is None: + self._reference_activity = acm.activity + self.setText(acm.activity.name) + + column_item = QtGui.QStandardItem() + column_item.setIcon(FileUtils.get_icon("table_column.svg")) + column_details = ( + f"{acm.metric_column.header}: " + f"{ActivityColumnMetricItem.metric_type_to_str(acm.metric_type)}" + ) + column_item.setText(column_details) + column_item.setToolTip(acm.expression) + self.appendRow(column_item) + + +class ActivityColumnSummaryTreeModel(QtGui.QStandardItemModel): + """View model for managing activity column metric data models in a tree view.""" + + def __init__(self, parent=None): + super().__init__(parent) + + self.setColumnCount(1) + + def set_summary_models( + self, activity_metric_models: typing.List[typing.List[ActivityColumnMetric]] + ): + """Update the model to use the specified activity column metric data models. + + Any existing items will be removed prior to loading the specified data models. + + :param activity_metric_models: Nested list of activity column metric data models. + :type activity_metric_models: typing.List[typing.List[ActivityColumnMetric]] + """ + # Clear any prior existing items + self.removeRows(0, self.rowCount()) + + for activity_metric_row in activity_metric_models: + summary_item = ActivityColumnSummaryItem(activity_metric_row) + self.appendRow(summary_item) diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index 076fc3689..10a8f066a 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -139,7 +139,7 @@ class MetricType(IntEnum): """Type of metric or expression.""" COLUMN = 0 - CUSTOM = 1 + CELL = 1 NOT_SET = 2 diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index cd04219ae..5d29ae915 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -355,7 +355,7 @@ Summary - Review and save the metrics configuration to be applied in the scenario analysis report. + Review and save the metrics configuration to be used in the scenario analysis report. 3 @@ -363,6 +363,9 @@ + + QAbstractItemView::NoEditTriggers + true From 40f7825d34dd6511381bf5fce2e85c57c34e78a0 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Thu, 31 Oct 2024 15:39:15 +0300 Subject: [PATCH 14/47] Remap column delegates on move column. --- .../gui/metrics_builder_dialog.py | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 2927fba4b..544903105 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -23,6 +23,7 @@ ActivityMetricTableModel, COLUMN_METRIC_STR, CELL_METRIC_STR, + HorizontalMoveDirection, MetricColumnListItem, MetricColumnListModel, ) @@ -534,20 +535,55 @@ def on_move_up_column(self): # We have normalized it to reflect the position in the # metrics table. reference_index = current_row + 1 + self._move_activity_metric_column(reference_index, HorizontalMoveDirection.LEFT) + + def _move_activity_metric_column( + self, reference_index: int, direction: HorizontalMoveDirection + ): + """Moves the activity column metric at the given index + depending on the direction. + + :param reference_index: Location of the reference column + that will be moved. + :type reference_index: int + + :param direction: Direction the reference column will + be moved. + :type direction: HorizontalMoveDirection + """ + if direction == HorizontalMoveDirection.LEFT: + adjacent_index = reference_index - 1 + else: + adjacent_index = reference_index + 1 + reference_delegate = self.tb_activity_metrics.itemDelegateForColumn( reference_index ) adjacent_delegate = self.tb_activity_metrics.itemDelegateForColumn( - reference_index - 1 + adjacent_index ) - new_index = self._activity_metric_table_model.move_column_left(reference_index) + + if direction == HorizontalMoveDirection.LEFT: + new_index = self._activity_metric_table_model.move_column_left( + reference_index + ) + else: + new_index = self._activity_metric_table_model.move_column_right( + reference_index + ) + if new_index != -1: + if direction == HorizontalMoveDirection.LEFT: + adjacent_new_index = new_index + 1 + else: + adjacent_new_index = new_index - 1 + # Also adjust the delegates self.tb_activity_metrics.setItemDelegateForColumn( new_index, reference_delegate ) self.tb_activity_metrics.setItemDelegateForColumn( - new_index + 1, adjacent_delegate + adjacent_new_index, adjacent_delegate ) def on_move_down_column(self): @@ -565,10 +601,13 @@ def on_move_down_column(self): # Maintain selection self.select_column(row) - # Move corresponding column in the activity metrics - # table. We have normalized it to reflect the position - # in the metrics table. - self._activity_metric_table_model.move_column_right(current_row + 1) + # Move corresponding column in the activity metrics table. + # We have normalized it to reflect the position in the + # metrics table. + reference_index = current_row + 1 + self._move_activity_metric_column( + reference_index, HorizontalMoveDirection.RIGHT + ) def select_column(self, row: int): """Select the column item in the specified row. From 2722ecd557978abf55fdd90b150845afc89b0b7c Mon Sep 17 00:00:00 2001 From: Kahiu Date: Sat, 2 Nov 2024 17:12:10 +0300 Subject: [PATCH 15/47] Add util for creating expression help in HTML. --- src/cplus_plugin/definitions/defaults.py | 4 +- .../gui/metrics_builder_dialog.py | 11 +- src/cplus_plugin/lib/reports/metrics.py | 155 ++++++++++++++++++ src/cplus_plugin/utils.py | 102 ++++++++++++ 4 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 src/cplus_plugin/lib/reports/metrics.py diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index 22b7e3646..1914a7e56 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -35,7 +35,9 @@ ) REPORT_DOCUMENTATION = "https://conservationinternational.github.io/cplus-plugin/user/guide/#report-generating" -OPTIONS_TITLE = "CPLUS" # Title in the QGIS settings +BASE_PLUGIN_NAME = "CPLUS" +# Title in the QGIS settings. Leave it like this for now incase title needs to change +OPTIONS_TITLE = BASE_PLUGIN_NAME GENERAL_OPTIONS_TITLE = "General" REPORT_OPTIONS_TITLE = "Reporting" LOG_OPTIONS_TITLE = "Logs" diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 544903105..7c4de9eaf 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -17,6 +17,10 @@ from ..conf import Settings, settings_manager from ..definitions.defaults import METRIC_ACTIVITY_AREA, USER_DOCUMENTATION_SITE +from ..lib.reports.metrics import ( + create_metrics_expression_context, + create_metrics_expression_scope, +) from .metrics_builder_model import ( ActivityColumnMetricItem, ActivityColumnSummaryTreeModel, @@ -130,7 +134,11 @@ def on_metric_type_changed(self, index: int): return expression_builder = QgsExpressionBuilderDialog( - None, activity_column_metric_item.expression, editor, "CPLUS" + None, + activity_column_metric_item.expression, + editor, + "CPLUS", + create_metrics_expression_context(), ) expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) if expression_builder.exec_() == QtWidgets.QDialog.Accepted: @@ -264,6 +272,7 @@ def __init__(self, parent=None, activities=None): self.cbo_column_expression.setExpressionDialogTitle( tr("Column Expression Builder") ) + self.cbo_column_expression.appendScope(create_metrics_expression_scope()) self.lst_columns.setModel(self._column_list_model) self.lst_columns.selectionModel().selectionChanged.connect( diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py new file mode 100644 index 000000000..6f3390fb8 --- /dev/null +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +""" +Provides variables and functions for custom activity metrics. +""" + +import typing + +from qgis.core import ( + QgsExpression, + QgsExpressionContext, + QgsExpressionFunction, + QgsExpressionContextScope, + QgsExpressionContextUtils, + QgsExpressionNodeFunction, + QgsProject, + QgsScopedExpressionFunction, +) + +from ...definitions.defaults import BASE_PLUGIN_NAME +from ...utils import FileUtils, function_help_to_html, log, open_documentation, tr + + +VAR_ACTIVITY_AREA = "cplus_activity_area" +VAR_ACTIVITY_NAME = "cplus_activity_name" +FUNC_FINANCIAL_DUMMY = "dummy_financial_viability" + + +class DummyFinancialComputation(QgsScopedExpressionFunction): + """Dummy function to set up the metrics framework. Will be removed.""" + + def __init__(self): + params = [ + QgsExpressionFunction.Parameter("area"), + QgsExpressionFunction.Parameter("inflation_coefficient"), + ] + + help_html = function_help_to_html( + FUNC_FINANCIAL_DUMMY, + tr("Calculates the sum of the two parameters value1 and value2."), + [ + ("float", "Current inflation rate", False), + ("float", "Base lending rate", True), + ], + [ + (f"{FUNC_FINANCIAL_DUMMY}(4.3, 11.2)", "56.7"), + (f"{FUNC_FINANCIAL_DUMMY}(8.5, 27.9)", "34.1"), + ], + ) + super().__init__( + FUNC_FINANCIAL_DUMMY, params, BASE_PLUGIN_NAME, helpText=help_html + ) + + def func( + self, + values: typing.List[typing.Any], + context: QgsExpressionContext, + parent: QgsExpression, + node: QgsExpressionNodeFunction, + ) -> typing.Any: + """Returns the result of evaluating the function. + + :param values: List of values passed to the function + :type values: typing.Iterable[typing.Any] + + :param context: Context expression is being evaluated against + :type context: QgsExpressionContext + + :param parent: Parent expression + :type parent: QgsExpression + + :param node: Expression node + :type node: QgsExpressionNodeFunction + + :returns: The result of the function. + :rtype: typing.Any + """ + area = int(values[0]) + coefficient = float(values[1]) + + return area * coefficient + + def clone(self) -> "DummyFinancialComputation": + """Gets a clone of this function. + + :returns: A clone of this function. + :rtype: DummyFinancialComputation + """ + return DummyFinancialComputation() + + +def create_metrics_expression_scope() -> QgsExpressionContextScope: + """Creates the expression context scope for activity metrics. + + The initial variable values will be arbitrary and will only be + updated just prior to the evaluation of the expression in a + separate function. + + :returns: The expression scope for activity metrics. + :rtype: QgsExpressionContextScope + """ + expression_scope = QgsExpressionContextScope(BASE_PLUGIN_NAME) + + # Activity area + expression_scope.addVariable( + QgsExpressionContextScope.StaticVariable( + VAR_ACTIVITY_AREA, + 1, + description=tr("The total area of the activity being evaluated."), + ) + ) + + # Activity name + expression_scope.addVariable( + QgsExpressionContextScope.StaticVariable( + VAR_ACTIVITY_NAME, + "", + description=tr("The name of the activity being evaluated."), + ) + ) + + # Add functions + expression_scope.addFunction(FUNC_FINANCIAL_DUMMY, DummyFinancialComputation()) + + return expression_scope + + +def create_metrics_expression_context( + project: QgsProject = None, +) -> QgsExpressionContext: + """Gets the expression context to use in the initial set up (e.g. + expression builder) or computation stage of activity metrics. + + It includes the global and project scopes. + + :param project: The QGIS project whose functions and variables + will be included in the expression context. If not specified, + the current project will be used. + :type project: QgsProject + + :returns: The expression to use in the customization of activity + metrics. + :rtype: QgsExpressionContext + """ + if project is None: + project = QgsProject.instance() + + builder_expression_context = QgsExpressionContext() + + builder_expression_context.appendScope(QgsExpressionContextUtils.globalScope()) + builder_expression_context.appendScope( + QgsExpressionContextUtils.projectScope(project) + ) + builder_expression_context.appendScope(create_metrics_expression_scope()) + + return builder_expression_context diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 02e058c43..19e8e09f7 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -6,6 +6,7 @@ import hashlib import json import os +import typing import uuid import datetime from pathlib import Path @@ -658,3 +659,104 @@ def get_layer_type(file_path: str): return 1 else: return -1 + + +def function_help_to_html( + function_name: str, + description: str, + arguments: typing.List[tuple] = None, + examples: typing.List[tuple] = None, +) -> str: + """Creates a HTML string containing the detailed help of an expression function. + + The specific HTML formatting is deduced from the code here: + https://github.com/qgis/QGIS/blob/master/src/core/expression/qgsexpression.cpp#L565 + + :param function_name: Name of the expression function. + :type function_name: str + + :param description: Detailed description of the function. + :type description: str + + :param arguments: List containing the arguments. Each argument should consist of a + tuple containing three elements i.e. argument name, description and bool where True + will indicate the argument is optional. Take note of the order as mandatory + arguments should be first in the list. + :type arguments: typing.List[tuple] + + :param examples: Examples of using the function. Each item in the list should be + a tuple containing an example expression and the corresponding return value. + :type examples: typing.List[tuple] + + :returns: The expression function's help in HTML for use in, for example, an + expression builder. + :rtype: str + """ + if arguments is None: + arguments = [] + + if examples is None: + examples = [] + + html_segments = [] + + # Title + html_segments.append(f"

function {function_name}

\n") + + # Description + html_segments.append(f'

{description}

') + + # Syntax + html_segments.append( + f'

Syntax

\n
\n' + f'{function_name}' + f"(" + ) + + has_optional = False + separator = "" + for arg in arguments: + arg_name = arg[0] + arg_mandatory = arg[2] + if not has_optional and arg_mandatory: + html_segments.append("[") + has_optional = True + + html_segments.append(separator) + html_segments.append(f'{arg_name}') + + if arg_mandatory: + html_segments.append("]") + + separator = "," + + html_segments.append(")") + + if has_optional: + html_segments.append("

[ ] marks optional components") + + # Arguments + if len(arguments) > 0: + html_segments.append('

Arguments

\n
\n') + for arg in arguments: + arg_name = arg[0] + arg_description = arg[1] + html_segments.append( + f'' + ) + + html_segments.append("
{arg_name}{arg_description}
\n
\n") + + # Examples + if len(examples) > 0: + html_segments.append('

Examples

\n
\n
    \n') + for example in examples: + expression = example[0] + return_value = example[1] + html_segments.append( + f"
  • {expression}{return_value}" + ) + + html_segments.append("
\n
\n") + + return "".join(html_segments) From b85b69fb51959a3ee3436b3d28b8f06caef028a4 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 4 Nov 2024 17:47:22 +0300 Subject: [PATCH 16/47] Enable loading of metric configuration. --- .../gui/metrics_builder_dialog.py | 67 ++++++++- src/cplus_plugin/gui/metrics_builder_model.py | 3 - src/cplus_plugin/gui/qgis_cplus_main.py | 11 +- src/cplus_plugin/lib/reports/metrics.py | 127 +++++++++++++++++- src/cplus_plugin/main.py | 17 +-- src/cplus_plugin/models/report.py | 62 ++++++++- 6 files changed, 259 insertions(+), 28 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 7c4de9eaf..358e26628 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -10,7 +10,7 @@ from qgis.core import Qgis, QgsVectorLayer from qgis.gui import QgsExpressionBuilderDialog, QgsGui, QgsMessageBar -from qgis.PyQt import QtCore, QtGui, QtWidgets +from qgis.PyQt import QtCore, QtWidgets from qgis.PyQt.uic import loadUiType @@ -32,7 +32,8 @@ MetricColumnListModel, ) from ..models.base import Activity -from ..models.report import MetricColumn, MetricType +from ..models.helpers import clone_activity +from ..models.report import MetricColumn, MetricConfiguration, MetricType from ..utils import FileUtils, log, open_documentation, tr WidgetUi, _ = loadUiType( @@ -47,6 +48,7 @@ class ColumnMetricItemDelegate(QtWidgets.QStyledItemDelegate): """ INDEX_PROPERTY_NAME = "delegate_index" + EXPRESSION_PROPERTY_NAME = "cell_expression" def createEditor( self, @@ -142,8 +144,9 @@ def on_metric_type_changed(self, index: int): ) expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) if expression_builder.exec_() == QtWidgets.QDialog.Accepted: - activity_column_metric_item.update_metric_type( - MetricType.CELL, expression_builder.expressionText() + # Save the expression in the combobox's custom property collection + editor.setProperty( + self.EXPRESSION_PROPERTY_NAME, expression_builder.expressionText() ) self.commitData.emit(editor) @@ -178,6 +181,8 @@ def setModelData( metric_column = model.metric_column(idx.column() - 1) if metric_column is not None: expression = metric_column.expression + elif metric_type == MetricType.CELL: + expression = widget.property(self.EXPRESSION_PROPERTY_NAME) item.update_metric_type(metric_type, expression) @@ -214,7 +219,7 @@ def __init__(self, parent=None, activities=None): self._activities = [] if activities is not None: - self._activities = activities + self._activities = [clone_activity(activity) for activity in activities] # Setup notification bars self._column_message_bar = QgsMessageBar() @@ -341,9 +346,59 @@ def activities(self, activities: typing.List[Activity]): those whose metrics will be used in the customization. :type activities: typing.List[Activity] """ - self._activities = activities + self._activities = [clone_activity(activity) for activity in activities] self._update_activities() + @property + def metric_configuration(self) -> MetricConfiguration: + """Gets the user configuration for metric column and + corresponding cell metric configuration. + + :returns: User metric configuration. + :rtype: MetricConfiguration + """ + return MetricConfiguration( + self._activity_metric_table_model.metric_columns, + self._activity_metric_table_model.models, + ) + + def load_configuration(self, configuration: MetricConfiguration): + """Load a metric configuration. + + All the columns in the configuration will be loaded, with an attempt + to restore the metric configuration of similar activities that + existed in the configuration with those currently being configured. + + :param configuration: Configuration containing mapping of metric + columns and cell metrics. + :type configuration: MetricConfiguration + """ + if configuration is None: + return + + if not configuration.is_valid(): + log("Metric configuration is invalid and cannot be loaded.") + return + + # Add metric columns + for mc in configuration.metric_columns: + item = MetricColumnListItem(mc) + self.add_column_item(item) + + # Configure activity cell metrics matching the same activity + # and column name in the configuration + for r in range(self._activity_metric_table_model.rowCount()): + for c in range(1, self._activity_metric_table_model.columnCount()): + item = self.item(r, c) + # Fetch the closest match in configuration (based on activity ID and name or header label) + model_match = configuration.find( + str(item.model.activity.uuid), item.model.metric_column.name + ) + if model_match is None: + continue + + item.update_metric_type(model_match.metric_type, model_match.expression) + def on_page_id_changed(self, page_id: int): """Slot raised when the page ID changes. diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index 83b34e236..b69ded113 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -480,9 +480,6 @@ def update_metric_type(self, metric_type: MetricType, expression: str = ""): Default is an empty string. :type expression: str """ - if self._activity_column_metric.metric_type == metric_type: - return - self._activity_column_metric.metric_type = metric_type self._activity_column_metric.expression = expression diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 626460006..332e4ebb8 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -106,6 +106,7 @@ ) from ..lib.reports.manager import report_manager, ReportManager from ..models.base import Scenario, ScenarioResult, ScenarioState, SpatialExtent +from ..models.report import MetricConfiguration from ..tasks import ScenarioAnalysisTask from ..utils import ( open_documentation, @@ -144,6 +145,9 @@ def __init__( self.processing_cancelled = False self.current_analysis_task = None + # Activity metric configuration + self._metric_configuration: MetricConfiguration = None + # Set icons for buttons help_icon = FileUtils.get_icon("mActionHelpContents_green.svg") self.help_btn.setIcon(help_icon) @@ -2440,8 +2444,13 @@ def on_show_metrics_wizard(self): """ metrics_builder = ActivityMetricsBuilder(self) metrics_builder.activities = self.selected_activities() + + # Load previously defined configuration + if self._metric_configuration is not None: + metrics_builder.load_configuration(self._metric_configuration) + if metrics_builder.exec_() == QtWidgets.QDialog.Accepted: - pass + self._metric_configuration = metrics_builder.metric_configuration def run_report(self, progress_dialog, report_manager): """Run report generation. This should be called after the diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 6f3390fb8..578e39a9d 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -3,15 +3,22 @@ Provides variables and functions for custom activity metrics. """ +import inspect +import traceback import typing +from qgis.PyQt.QtCore import QCoreApplication + from qgis.core import ( + Qgis, QgsExpression, QgsExpressionContext, QgsExpressionFunction, QgsExpressionContextScope, QgsExpressionContextUtils, QgsExpressionNodeFunction, + QgsFeatureRequest, + QgsMessageLog, QgsProject, QgsScopedExpressionFunction, ) @@ -75,9 +82,9 @@ def func( :rtype: typing.Any """ area = int(values[0]) - coefficient = float(values[1]) + # coefficient = float(values[1]) - return area * coefficient + return 42 def clone(self) -> "DummyFinancialComputation": """Gets a clone of this function. @@ -88,6 +95,75 @@ def clone(self) -> "DummyFinancialComputation": return DummyFinancialComputation() +class TestExpressionFunction(QgsExpressionFunction): + """Python expression function""" + + def __init__( + self, + name, + group, + helptext="", + usesgeometry=False, + referenced_columns=None, + handlesnull=False, + params_as_list=False, + ): + # Call the parent constructor + QgsExpressionFunction.__init__(self, name, 0, group, helptext) + if referenced_columns is None: + referenced_columns = [QgsFeatureRequest.ALL_ATTRIBUTES] + self.params_as_list = params_as_list + self.usesgeometry = usesgeometry + self.referenced_columns = referenced_columns + self.handlesnull = handlesnull + + def usesGeometry(self, node): + return self.usesgeometry + + def referencedColumns(self, node): + return self.referenced_columns + + def handlesNull(self): + return self.handlesnull + + def isContextual(self): + return True + + def func(self, values, context, parent, node): + feature = None + if context: + feature = context.feature() + + try: + # Inspect the inner function signature to get the list of parameters + parameters = inspect.signature(self.function).parameters + kwvalues = {} + + # Handle special parameters + # those will not be inserted in the arguments list + # if they are present in the function signature + if "context" in parameters: + kwvalues["context"] = context + if "feature" in parameters: + kwvalues["feature"] = feature + if "parent" in parameters: + kwvalues["parent"] = parent + + # In this context, values is a list of the parameters passed to the expression. + # If self.params_as_list is True, values is passed as is to the inner function. + if self.params_as_list: + return self.function(values, **kwvalues) + # Otherwise (default), the parameters are expanded + return self.function(*values, **kwvalues) + + except Exception as ex: + tb = traceback.format_exception(None, ex, ex.__traceback__) + formatted_traceback = "".join(tb) + formatted_exception = f"{ex}:
{formatted_traceback}
" + parent.setEvalErrorString(formatted_exception) + return None + + def create_metrics_expression_scope() -> QgsExpressionContextScope: """Creates the expression context scope for activity metrics. @@ -119,11 +195,56 @@ def create_metrics_expression_scope() -> QgsExpressionContextScope: ) # Add functions - expression_scope.addFunction(FUNC_FINANCIAL_DUMMY, DummyFinancialComputation()) + # expression_scope.addFunction(FUNC_FINANCIAL_DUMMY, DummyFinancialComputation()) return expression_scope +def register_metric_functions(): + """Register our custom functions with the expression engine.""" + # QgsExpression.registerFunction(DummyFinancialComputation()) + + register = True + name = FUNC_FINANCIAL_DUMMY + if register and QgsExpression.isFunctionName(name): + if not QgsExpression.unregisterFunction(name): + msgtitle = QCoreApplication.translate("UserExpressions", "User expressions") + msg = QCoreApplication.translate( + "UserExpressions", + "The user expression {0} already exists and could not be unregistered.", + ).format(name) + QgsMessageLog.logMessage(msg + "\n", msgtitle, Qgis.MessageLevel.Warning) + + group = "CPLUS" + helptext = "" + usesgeometry = False + referenced_columns = [QgsFeatureRequest.ALL_ATTRIBUTES] + handlesnull = False + f = TestExpressionFunction( + name, group, helptext, usesgeometry, referenced_columns, handlesnull, False + ) + QgsExpression.registerFunction(f, True) + + functions = QgsExpression.Functions() + idx = QgsExpression.functionIndex(name) + func = functions[idx] + log(f"Name: {func.name()}") + log(f"Groups: {str(func.groups())}") + # log(f"Help: {func.helpText()}") + + func_prev = functions[idx - 2] + log(f"Name: {func_prev.name()}") + log(f"Groups: {str(func_prev.groups())}") + # log(f"Help: {func_prev.helpText()}") + + +def unregister_metric_functions(): + """Unregister the custom metric functions from the expression + engine. + """ + QgsExpression.unregisterFunction(FUNC_FINANCIAL_DUMMY) + + def create_metrics_expression_context( project: QgsProject = None, ) -> QgsExpressionContext: diff --git a/src/cplus_plugin/main.py b/src/cplus_plugin/main.py index 656d08a3b..3c11aede7 100644 --- a/src/cplus_plugin/main.py +++ b/src/cplus_plugin/main.py @@ -16,7 +16,6 @@ from qgis.core import ( QgsApplication, - QgsColorBrewerColorRamp, QgsMasterLayoutInterface, QgsSettings, ) @@ -33,19 +32,6 @@ from qgis.PyQt.QtWidgets import QMenu from .conf import Settings, settings_manager -from .definitions.constants import ( - CARBON_PATHS_ATTRIBUTE, - COLOR_RAMP_PROPERTIES_ATTRIBUTE, - COLOR_RAMP_TYPE_ATTRIBUTE, - ACTIVITY_LAYER_STYLE_ATTRIBUTE, - NCS_CARBON_SEGMENT, - NCS_PATHWAY_SEGMENT, - PATH_ATTRIBUTE, - PIXEL_VALUE_ATTRIBUTE, - STYLE_ATTRIBUTE, - USER_DEFINED_ATTRIBUTE, - UUID_ATTRIBUTE, -) from .definitions.defaults import ( ABOUT_DOCUMENTATION_SITE, CI_LOGO_PATH, @@ -63,6 +49,7 @@ from .gui.map_repeat_item_widget import CplusMapLayoutItemGuiMetadata from .lib.reports.layout_items import CplusMapRepeatItemLayoutItemMetadata from .lib.reports.manager import report_manager +from .lib.reports.metrics import register_metric_functions, unregister_metric_functions from .gui.settings.cplus_options import CplusOptionsFactory from .gui.settings.log_options import LogOptionsFactory from .gui.settings.report_options import ReportOptionsFactory @@ -274,6 +261,8 @@ def initGui(self): # Install report font self.install_report_font() + # register_metric_functions() + def onClosePlugin(self): """Cleanup necessary items here when plugin widget is closed.""" self.pluginIsActive = False diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index 10a8f066a..c6605d7f9 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -179,6 +179,66 @@ class MetricConfiguration: activity column metric data models. """ - activities: typing.List[Activity] metric_columns: typing.List[MetricColumn] activity_metrics: typing.List[typing.List[ActivityColumnMetric]] + + def is_valid(self) -> bool: + """Checks the validity of the configuration. + + It verifies if the number of metric columns matches the + column mappings for activity metrics. + + :returns: True if the configuration is valid, else False. + :rtype: bool + """ + column_metrics_len = 0 + if len(self.activity_metrics) > 0: + column_metrics_len = len(self.activity_metrics[0]) + + return len(self.metric_columns) == column_metrics_len + + @property + def activities(self) -> typing.List[Activity]: + """Gets the activity models in the configuration. + + :returns: Activity models in the configuration. + :rtype: typing.List[Activity] + """ + activities = [] + for activity_row in self.activity_metrics: + if len(activity_row) > 0: + activities.append(activity_row[0].activity) + + return activities + + def find( + self, activity_id: str, name_header: str + ) -> typing.Optional[ActivityColumnMetric]: + """Returns a matching activity column metric model + for the activity with the given UUID and the corresponding + metric column name or header label. + + :param activity_id: The activity's unique identifier. + :type activity_id: str + + :param name_header: The metric column name or header to match. + :type name_header: str + + :returns: Matching column metric or None if not found. + :rtype: typing.Optional[ActivityColumnMetric] + """ + + def _search_list(model_list: typing.List, activity_identifier: str, name: str): + for model in model_list: + if isinstance(model, list): + yield from _search_list(model, activity_identifier, name) + else: + if str(model.activity.uuid) == activity_identifier and ( + model.metric_column.name == name + or model.metric_column.name == name + ): + yield model + + match = next(_search_list(self.activity_metrics, activity_id, name_header), -1) + + return match if match != -1 else None From 06007b21b1c51f30b589b5478986b82239e40a5d Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 4 Nov 2024 21:28:21 +0300 Subject: [PATCH 17/47] Use activation to show expression builder for cell metrics. --- .../gui/metrics_builder_dialog.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index 358e26628..f4974c48a 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -43,7 +43,7 @@ class ColumnMetricItemDelegate(QtWidgets.QStyledItemDelegate): """ - Delegate for enabling the user to choose the type of metric for a + Delegate that allows the user to choose the type of metric for a particular activity column. """ @@ -75,7 +75,7 @@ def createEditor( metric_combobox.setProperty(self.INDEX_PROPERTY_NAME, idx) metric_combobox.addItem(tr(COLUMN_METRIC_STR), MetricType.COLUMN) metric_combobox.addItem(tr(CELL_METRIC_STR), MetricType.CELL) - metric_combobox.currentIndexChanged.connect(self.on_metric_type_changed) + metric_combobox.activated.connect(self.on_metric_type_changed) return metric_combobox @@ -144,7 +144,7 @@ def on_metric_type_changed(self, index: int): ) expression_builder.setWindowTitle(tr("Activity Column Expression Builder")) if expression_builder.exec_() == QtWidgets.QDialog.Accepted: - # Save the expression in the combobox's custom property collection + # Save the expression for use when persisting in the model editor.setProperty( self.EXPRESSION_PROPERTY_NAME, expression_builder.expressionText() ) @@ -175,9 +175,9 @@ def setModelData( if item is None or not isinstance(item, ActivityColumnMetricItem): return - # Inherit the column expression if defined expression = "" if metric_type == MetricType.COLUMN: + # Inherit the column expression if defined metric_column = model.metric_column(idx.column() - 1) if metric_column is not None: expression = metric_column.expression @@ -382,6 +382,10 @@ def load_configuration(self, configuration: MetricConfiguration): # Add metric columns for mc in configuration.metric_columns: + # Do not add a column with a similar name + if self._column_list_model.column_exists(mc.name): + continue + item = MetricColumnListItem(mc) self.add_column_item(item) @@ -389,8 +393,9 @@ def load_configuration(self, configuration: MetricConfiguration): # and column name in the configuration for r in range(self._activity_metric_table_model.rowCount()): for c in range(1, self._activity_metric_table_model.columnCount()): - item = self.item(r, c) - # Fetch the closest match in configuration (based on activity ID and name or header label) + item = self._activity_metric_table_model.item(r, c) + # Fetch the closest match in configuration (based on activity + # ID and name or header label) model_match = configuration.find( str(item.model.activity.uuid), item.model.metric_column.name ) @@ -551,9 +556,16 @@ def on_add_column(self): def add_column_item(self, item: MetricColumnListItem): """Adds a metric column item. + If there is a column with a similar name, the item + will not be added. + :param item: Metrics column item to be added. :type item: MetricColumnListItem """ + # Check if there are items with a similar name + if self._column_list_model.column_exists(item.name): + return + self._column_list_model.add_column(item) # Select item From 95a606d0b59222b5d566867e2c12977d26e2704d Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 4 Nov 2024 22:36:37 +0300 Subject: [PATCH 18/47] Fix metric column positioning on moving columns. --- .../gui/metrics_builder_dialog.py | 33 +++++++++++++++---- src/cplus_plugin/gui/metrics_builder_model.py | 6 +++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/cplus_plugin/gui/metrics_builder_dialog.py b/src/cplus_plugin/gui/metrics_builder_dialog.py index f4974c48a..d11308f22 100644 --- a/src/cplus_plugin/gui/metrics_builder_dialog.py +++ b/src/cplus_plugin/gui/metrics_builder_dialog.py @@ -211,6 +211,8 @@ def updateEditorGeometry( class ActivityMetricsBuilder(QtWidgets.QWizard, WidgetUi): """Wizard for customizing custom activity metrics table.""" + AREA_COLUMN = "Area" + def __init__(self, parent=None, activities=None): super().__init__(parent) self.setupUi(self) @@ -305,7 +307,10 @@ def __init__(self, parent=None, activities=None): # Add the default area column area_metric_column = MetricColumn( - "Area", tr("Area (Ha)"), METRIC_ACTIVITY_AREA, auto_calculated=True + self.AREA_COLUMN, + tr("Area (Ha)"), + METRIC_ACTIVITY_AREA, + auto_calculated=True, ) area_column_item = MetricColumnListItem(area_metric_column) self.add_column_item(area_column_item) @@ -380,6 +385,9 @@ def load_configuration(self, configuration: MetricConfiguration): log("Metric configuration is invalid and cannot be loaded.") return + # Remove the default area + self.remove_column(self.AREA_COLUMN) + # Add metric columns for mc in configuration.metric_columns: # Do not add a column with a similar name @@ -584,14 +592,27 @@ def on_remove_column(self): """Slot raised to remove the selected column.""" selected_items = self.selected_column_items() for item in selected_items: - index = item.row() - self._column_list_model.remove_column(item.name) - - # Remove corresponding column in activity metrics table - self._activity_metric_table_model.remove_column(index) + self.remove_column(item.name) self.resize_activity_table_columns() + def remove_column(self, name: str): + """Remove a metric column with the given name. + + :param name: Name of the metric column to be removed. + :type name: str + """ + item = self._column_list_model.item_from_name(name) + if item is None: + return + + index = item.row() + + self._column_list_model.remove_column(name) + + # Remove corresponding column in activity metrics table + self._activity_metric_table_model.remove_column(index) + def on_move_up_column(self): """Slot raised to move the selected column one level up.""" selected_items = self.selected_column_items() diff --git a/src/cplus_plugin/gui/metrics_builder_model.py b/src/cplus_plugin/gui/metrics_builder_model.py index b69ded113..21891e666 100644 --- a/src/cplus_plugin/gui/metrics_builder_model.py +++ b/src/cplus_plugin/gui/metrics_builder_model.py @@ -767,7 +767,7 @@ def move_column( item was not moved. :rtype: int """ - # The activity name column will always be on the extreme left (LTR) + # The activity name column will always be on the extreme left if current_index <= 1 and direction == HorizontalMoveDirection.LEFT: return -1 @@ -788,6 +788,10 @@ def move_column( self.insertColumn(new_index, column_items) self.setHorizontalHeaderItem(new_index, header_item) + # Also reposition metric columns + metric_column = self._metric_columns.pop(current_index - 1) + self._metric_columns.insert(new_index - 1, metric_column) + return new_index def move_column_left(self, current_index: int) -> int: From 55cfdc3ab8d390568c4dec63e7b7d80908843688 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 6 Nov 2024 09:03:57 +0300 Subject: [PATCH 19/47] Incorporate save/load of metric configurations in settings. --- src/cplus_plugin/conf.py | 37 +++- src/cplus_plugin/definitions/constants.py | 10 + src/cplus_plugin/gui/qgis_cplus_main.py | 13 +- src/cplus_plugin/models/helpers.py | 175 +++++++++++++++++- src/cplus_plugin/models/report.py | 22 +++ .../ui/activity_metrics_builder_dialog.ui | 52 +++++- 6 files changed, 289 insertions(+), 20 deletions(-) diff --git a/src/cplus_plugin/conf.py b/src/cplus_plugin/conf.py index 750cc2592..92e4efcc1 100644 --- a/src/cplus_plugin/conf.py +++ b/src/cplus_plugin/conf.py @@ -17,7 +17,7 @@ from qgis.core import QgsSettings from .definitions.constants import ( - STYLE_ATTRIBUTE, + METRIC_CONFIGURATION_PROPERTY, NCS_CARBON_SEGMENT, NCS_PATHWAY_SEGMENT, NPV_COLLECTION_PROPERTY, @@ -26,6 +26,7 @@ PATHWAYS_ATTRIBUTE, PIXEL_VALUE_ATTRIBUTE, PRIORITY_LAYERS_SEGMENT, + STYLE_ATTRIBUTE, UUID_ATTRIBUTE, ) from .definitions.defaults import PRIORITY_LAYERS @@ -41,10 +42,13 @@ activity_npv_collection_to_dict, create_activity, create_activity_npv_collection, + create_metric_configuration, create_ncs_pathway, layer_component_to_dict, + metric_configuration_to_dict, ncs_pathway_to_dict, ) +from .models.report import MetricConfiguration from .utils import log, todict, CustomJsonEncoder @@ -1477,6 +1481,37 @@ def save_npv_collection(self, npv_collection: ActivityNpvCollection): npv_collection_str = json.dumps(npv_collection_dict) self.set_value(NPV_COLLECTION_PROPERTY, npv_collection_str) + def get_metric_configuration(self) -> typing.Optional[MetricConfiguration]: + """Gets the activity metric configuration. + + :returns: The activity metric configuration or None + if not defined or if an error occurred when deserializing. + :rtype: MetricConfiguration + """ + metric_configuration_str = self.get_value(METRIC_CONFIGURATION_PROPERTY, None) + if not metric_configuration_str: + return None + + metric_configuration_dict = {} + try: + metric_configuration_dict = json.loads(metric_configuration_str) + except json.JSONDecodeError: + log("Metric configuration JSON is invalid.") + + return create_metric_configuration( + metric_configuration_dict, self.get_all_activities() + ) + + def save_metric_configuration(self, metric_configuration: MetricConfiguration): + """Serializes the metric configuration in settings as a JSON string. + + :param metric_configuration: Activity NPV collection serialized to a JSON string. + :type metric_configuration: ActivityNpvCollection + """ + metric_configuration_dict = metric_configuration_to_dict(metric_configuration) + metric_configuration_str = json.dumps(metric_configuration_dict) + self.set_value(METRIC_CONFIGURATION_PROPERTY, metric_configuration_str) + def save_online_scenario(self, scenario_uuid): """Save the passed scenario settings into the plugin settings as online tasl diff --git a/src/cplus_plugin/definitions/constants.py b/src/cplus_plugin/definitions/constants.py index 3f02d8632..55ce7df3c 100644 --- a/src/cplus_plugin/definitions/constants.py +++ b/src/cplus_plugin/definitions/constants.py @@ -48,9 +48,19 @@ NPV_MAPPINGS_ATTRIBUTE = "mappings" REMOVE_EXISTING_ATTRIBUTE = "remove_existing" MANUAL_NPV_ATTRIBUTE = "manual_npv" +HEADER_ATTRIBUTE = "header" +EXPRESSION_ATTRIBUTE = "expression" +ALIGNMENT_ATTRIBUTE = "alignment" +AUTO_CALCULATED_ATTRIBUTE = "auto_calculated" +METRIC_TYPE_ATTRIBUTE = "metric_type" ACTIVITY_IDENTIFIER_PROPERTY = "activity_identifier" +MULTI_ACTIVITY_IDENTIFIER_PROPERTY = "activity_identifiers" NPV_COLLECTION_PROPERTY = "npv_collection" +METRIC_IDENTIFIER_PROPERTY = "metric_identifier" +METRIC_COLUMNS_PROPERTY = "metric_columns" +METRIC_CONFIGURATION_PROPERTY = "metric_configuration" +ACTIVITY_METRICS_PROPERTY = "activity_metrics" # Option / settings keys CPLUS_OPTIONS_KEY = "cplus_main" diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 332e4ebb8..6ca7f0ee8 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -145,9 +145,6 @@ def __init__( self.processing_cancelled = False self.current_analysis_task = None - # Activity metric configuration - self._metric_configuration: MetricConfiguration = None - # Set icons for buttons help_icon = FileUtils.get_icon("mActionHelpContents_green.svg") self.help_btn.setIcon(help_icon) @@ -2445,12 +2442,14 @@ def on_show_metrics_wizard(self): metrics_builder = ActivityMetricsBuilder(self) metrics_builder.activities = self.selected_activities() - # Load previously defined configuration - if self._metric_configuration is not None: - metrics_builder.load_configuration(self._metric_configuration) + # Load previously saved configuration + metric_configuration = settings_manager.get_metric_configuration() + if metric_configuration is not None: + metrics_builder.load_configuration(metric_configuration) if metrics_builder.exec_() == QtWidgets.QDialog.Accepted: - self._metric_configuration = metrics_builder.metric_configuration + metric_configuration = metrics_builder.metric_configuration + settings_manager.save_metric_configuration(metric_configuration) def run_report(self, progress_dialog, report_manager): """Run report generation. This should be called after the diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index 84dd8ab7e..d78362bdd 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -27,25 +27,34 @@ ) from ..definitions.constants import ( ACTIVITY_IDENTIFIER_PROPERTY, + ACTIVITY_METRICS_PROPERTY, ABSOLUTE_NPV_ATTRIBUTE, + ALIGNMENT_ATTRIBUTE, + AUTO_CALCULATED_ATTRIBUTE, CARBON_PATHS_ATTRIBUTE, COMPUTED_ATTRIBUTE, DISCOUNT_ATTRIBUTE, - ENABLED_ATTRIBUTE, - STYLE_ATTRIBUTE, - NAME_ATTRIBUTE, DESCRIPTION_ATTRIBUTE, + ENABLED_ATTRIBUTE, + EXPRESSION_ATTRIBUTE, + HEADER_ATTRIBUTE, LAYER_TYPE_ATTRIBUTE, MANUAL_NPV_ATTRIBUTE, MASK_PATHS_SEGMENT, + METRIC_IDENTIFIER_PROPERTY, + METRIC_TYPE_ATTRIBUTE, NPV_MAPPINGS_ATTRIBUTE, MAX_VALUE_ATTRIBUTE, MIN_VALUE_ATTRIBUTE, + METRIC_COLUMNS_PROPERTY, + MULTI_ACTIVITY_IDENTIFIER_PROPERTY, + NAME_ATTRIBUTE, NORMALIZED_NPV_ATTRIBUTE, PATH_ATTRIBUTE, PIXEL_VALUE_ATTRIBUTE, PRIORITY_LAYERS_SEGMENT, REMOVE_EXISTING_ATTRIBUTE, + STYLE_ATTRIBUTE, USER_DEFINED_ATTRIBUTE, UUID_ATTRIBUTE, YEARS_ATTRIBUTE, @@ -53,6 +62,7 @@ ) from ..definitions.defaults import DEFAULT_CRS_ID, QGIS_GDAL_PROVIDER from .financial import ActivityNpv, ActivityNpvCollection, NpvParameters +from .report import ActivityColumnMetric, MetricColumn, MetricConfiguration, MetricType from ..utils import log @@ -630,3 +640,162 @@ def layer_from_scenario_result( return None return layer + + +def metric_column_to_dict(metric_column: MetricColumn) -> dict: + """Converts a metric column object to a dictionary representation. + + :param metric_column: Metric column to be serialized to a dictionary. + :type metric_column: MetricColumn + + :returns: A dictionary containing attribute values of a metric column. + :rtype: dict + """ + return { + NAME_ATTRIBUTE: metric_column.name, + HEADER_ATTRIBUTE: metric_column.header, + EXPRESSION_ATTRIBUTE: metric_column.expression, + ALIGNMENT_ATTRIBUTE: metric_column.alignment, + AUTO_CALCULATED_ATTRIBUTE: metric_column.auto_calculated, + } + + +def create_metric_column(metric_column_dict: dict) -> typing.Optional[MetricColumn]: + """Creates a metric column from the equivalent dictionary representation. + + :param metric_column_dict: Dictionary containing information for deserializing + the dict to a metric column. + :type metric_column_dict: dict + + :returns: Metric column object or None if the deserialization failed. + :rtype: MetricColumn + """ + return MetricColumn( + metric_column_dict[NAME_ATTRIBUTE], + metric_column_dict[HEADER_ATTRIBUTE], + metric_column_dict[EXPRESSION_ATTRIBUTE], + metric_column_dict[ALIGNMENT_ATTRIBUTE], + metric_column_dict[AUTO_CALCULATED_ATTRIBUTE], + ) + + +def activity_metric_to_dict(activity_metric: ActivityColumnMetric) -> dict: + """Converts an activity column metric to a dictionary representation. + + :param activity_metric: Activity column metric to be serialized to a dictionary. + :type activity_metric: ActivityColumnMetric + + :returns: A dictionary containing attribute values of an + activity column metric. + :rtype: dict + """ + return { + ACTIVITY_IDENTIFIER_PROPERTY: str(activity_metric.activity.uuid), + METRIC_IDENTIFIER_PROPERTY: activity_metric.metric_column.name, + METRIC_TYPE_ATTRIBUTE: activity_metric.metric_type.value, + EXPRESSION_ATTRIBUTE: activity_metric.expression, + } + + +def create_activity_metric( + activity_metric_dict: dict, activity: Activity, metric_column: MetricColumn +) -> typing.Optional[ActivityColumnMetric]: + """Creates a metric column from the equivalent dictionary representation. + + :param activity_metric_dict: Dictionary containing information for deserializing + the dict to a metric column. + :type activity_metric_dict: dict + + :param activity: Referenced activity matching the saved UUID. + :type activity: str + + :param metric_column: Referenced metric column matching the saved name. + :type metric_column: MetricColumn + + :returns: Metric column object or None if the deserialization failed. + :rtype: MetricColumn + """ + return ActivityColumnMetric( + activity, + metric_column, + MetricType.from_int(activity_metric_dict[METRIC_TYPE_ATTRIBUTE]), + activity_metric_dict[EXPRESSION_ATTRIBUTE], + ) + + +def metric_configuration_to_dict(metric_configuration: MetricConfiguration) -> dict: + """Serializes a metric configuration to dict. + + :param metric_configuration: Metric configuration to tbe serialized. + :type metric_configuration: MetricConfiguration + + :returns: A dictionary representing a metric configuration. + :rtype: MetricConfiguration + """ + metric_config_dict = {} + + metric_column_dicts = [ + metric_column_to_dict(mc) for mc in metric_configuration.metric_columns + ] + metric_config_dict[METRIC_COLUMNS_PROPERTY] = metric_column_dicts + + activity_column_metrics = [] + for activity_columns in metric_configuration.activity_metrics: + column_metrics = [] + for activity_column_metric in activity_columns: + column_metrics.append(activity_metric_to_dict(activity_column_metric)) + activity_column_metrics.append(column_metrics) + + metric_config_dict[ACTIVITY_METRICS_PROPERTY] = activity_column_metrics + + activity_identifiers = [ + str(activity.uuid) for activity in metric_configuration.activities + ] + metric_config_dict[MULTI_ACTIVITY_IDENTIFIER_PROPERTY] = activity_identifiers + + return metric_config_dict + + +def create_metric_configuration( + metric_configuration_dict: dict, referenced_activities: typing.List[Activity] +) -> typing.Optional[MetricConfiguration]: + """Creates a metric configuration from the equivalent dictionary representation. + + :param metric_configuration_dict: Dictionary containing information for deserializing + a metric configuration object + :type metric_configuration_dict: dict + + :param referenced_activities: Activities that are referenced in the metric configuration. + :type referenced_activities: typing.List[Activity] + + :returns: Metric configuration object or None if the deserialization failed. + :rtype: MetricConfiguration + """ + if len(metric_configuration_dict) == 0: + return None + + metric_column_dicts = metric_configuration_dict[METRIC_COLUMNS_PROPERTY] + metric_columns = [create_metric_column(mc_dict) for mc_dict in metric_column_dicts] + + indexed_metric_columns = {mc.name: mc for mc in metric_columns} + indexed_activities = { + str(activity.uuid): activity for activity in referenced_activities + } + + activity_column_metrics = [] + activity_column_metric_dicts = metric_configuration_dict[ACTIVITY_METRICS_PROPERTY] + for activity_row_dict in activity_column_metric_dicts: + activity_row_metrics = [] + for activity_metric_dict in activity_row_dict: + activity_id = activity_metric_dict[ACTIVITY_IDENTIFIER_PROPERTY] + name = activity_metric_dict[METRIC_IDENTIFIER_PROPERTY] + activity = indexed_activities[activity_id] + metric_column = indexed_metric_columns[name] + + activity_row_metrics.append( + create_activity_metric(activity_metric_dict, activity, metric_column) + ) + + activity_column_metrics.append(activity_row_metrics) + + return MetricConfiguration(metric_columns, activity_column_metrics) diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index c6605d7f9..e1bb2f83b 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -141,6 +141,28 @@ class MetricType(IntEnum): COLUMN = 0 CELL = 1 NOT_SET = 2 + UNKNOWN = 3 + + @staticmethod + def from_int(int_enum: int) -> "MetricType": + """Creates the metric type enum from the + corresponding int equivalent. + + :param int_enum: Integer representing the metric type. + :type int_enum: int + + :returns: Metric type enum corresponding to the given + int else unknown if not found. + :rtype: MetricType + """ + if int_enum == 0: + return MetricType.COLUMN + elif int_enum == 1: + return MetricType.CELL + elif int_enum == 2: + return MetricType.NOT_SET + else: + return MetricType.UNKNOWN @dataclasses.dataclass diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 5d29ae915..1fc9c1b94 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -6,7 +6,7 @@ 0 0 - 574 + 746 486 @@ -48,7 +48,16 @@ QFrame::Raised - + + 2 + + + 2 + + + 2 + + 2 @@ -106,7 +115,16 @@ 1 - + + 0 + + + 0 + + + 0 + + 0 @@ -125,12 +143,21 @@ 0 0 - 556 - 382 + 728 + 395 - + + 0 + + + 0 + + + 0 + + 0 @@ -241,7 +268,7 @@ - Header label + <html><head/><body><p>Header label <span style=" color:#ff0000;">*</span></p></body></html> @@ -270,12 +297,12 @@ - + - + Qt::Vertical @@ -295,6 +322,13 @@ + + + + <html><head/><body><p><span style=" font-style:italic; color:#ff0000;">*</span><span style=" font-style:italic;"> Required field</span></p></body></html> + + + From 3a5bccd9d6810a152df05963a5af8e0a845e36be Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 6 Nov 2024 17:33:15 +0300 Subject: [PATCH 20/47] Validate metric configuration before running scenario. --- src/cplus_plugin/gui/qgis_cplus_main.py | 48 +++++++++++++++++++++++++ src/cplus_plugin/models/helpers.py | 15 ++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/cplus_plugin/gui/qgis_cplus_main.py b/src/cplus_plugin/gui/qgis_cplus_main.py index 6ca7f0ee8..e20185194 100644 --- a/src/cplus_plugin/gui/qgis_cplus_main.py +++ b/src/cplus_plugin/gui/qgis_cplus_main.py @@ -1533,6 +1533,48 @@ def prepare_message_bar(self): ) self.dock_widget_contents.layout().insertLayout(0, self.grid_layout) + def is_metric_configuration_valid(self) -> bool: + """Checks if the setup of the metrics configuration for the scenario analysis report is correct. + + :returns: True if the configuration is correct else False. + :rtype: bool + """ + if not self.chb_metric_builder.isChecked(): + # Not applicable so just return True + return True + else: + metric_configuration = settings_manager.get_metric_configuration() + if metric_configuration is None or not metric_configuration.is_valid(): + self.show_message( + tr( + f"Metrics configuration is invalid or not yet defined. " + f"Use the metrics builder to verify." + ) + ) + return False + + # Compare activities + selected_activities_ids = set( + [str(activity.uuid) for activity in self.selected_activities()] + ) + metric_activity_ids = set( + [str(activity.uuid) for activity in metric_configuration.activities] + ) + if selected_activities_ids == metric_activity_ids: + return True + elif selected_activities_ids.issubset(metric_activity_ids): + return True + elif len(selected_activities_ids.difference(metric_activity_ids)) > 0: + self.show_message( + tr( + f"There are activities whose metrics has not not been " + f"defined. Use the metrics builder to update." + ) + ) + return False + + return True + def run_analysis(self): """Runs the plugin analysis Creates new QgsTask, progress dialog and report manager @@ -1540,6 +1582,12 @@ def run_analysis(self): """ self.log_text_box.clear() + if not self.is_metric_configuration_valid(): + log( + "Scenario cannot run due to an invalid metrics configuration. See preceding errors." + ) + return + extent_list = PILOT_AREA_EXTENT["coordinates"] default_extent = QgsRectangle( extent_list[0], extent_list[2], extent_list[1], extent_list[3] diff --git a/src/cplus_plugin/models/helpers.py b/src/cplus_plugin/models/helpers.py index d78362bdd..3129fd31c 100644 --- a/src/cplus_plugin/models/helpers.py +++ b/src/cplus_plugin/models/helpers.py @@ -762,10 +762,11 @@ def create_metric_configuration( """Creates a metric configuration from the equivalent dictionary representation. :param metric_configuration_dict: Dictionary containing information for deserializing - a metric configuration object + a metric configuration object. :type metric_configuration_dict: dict - :param referenced_activities: Activities that are referenced in the metric configuration. + :param referenced_activities: Activities which will be used to extract those + referenced in the metric configuration. :type referenced_activities: typing.List[Activity] :returns: Metric configuration object or None if the deserialization failed. @@ -785,9 +786,17 @@ def create_metric_configuration( activity_column_metrics = [] activity_column_metric_dicts = metric_configuration_dict[ACTIVITY_METRICS_PROPERTY] for activity_row_dict in activity_column_metric_dicts: + if len(activity_row_dict) == 0: + continue + + # Check if the activity exists + activity_id = activity_row_dict[0][ACTIVITY_IDENTIFIER_PROPERTY] + if activity_id not in indexed_activities: + # Most likely the activity in the metric config has been deleted + continue + activity_row_metrics = [] for activity_metric_dict in activity_row_dict: - activity_id = activity_metric_dict[ACTIVITY_IDENTIFIER_PROPERTY] name = activity_metric_dict[METRIC_IDENTIFIER_PROPERTY] activity = indexed_activities[activity_id] metric_column = indexed_metric_columns[name] From 0064d4c01293e256dfdf746405537cb51abf42ec Mon Sep 17 00:00:00 2001 From: Kahiu Date: Wed, 6 Nov 2024 19:50:57 +0300 Subject: [PATCH 21/47] Incorporate custom metrics in report framework. --- ...ysis.qpt => scenario_analysis_default.qpt} | 0 src/cplus_plugin/definitions/defaults.py | 3 +- src/cplus_plugin/lib/reports/generator.py | 5 +++ src/cplus_plugin/lib/reports/manager.py | 39 +++++++++++++++++-- src/cplus_plugin/models/report.py | 1 + src/cplus_plugin/utils.py | 2 +- 6 files changed, 44 insertions(+), 6 deletions(-) rename src/cplus_plugin/data/report_templates/{scenario_analysis.qpt => scenario_analysis_default.qpt} (100%) diff --git a/src/cplus_plugin/data/report_templates/scenario_analysis.qpt b/src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt similarity index 100% rename from src/cplus_plugin/data/report_templates/scenario_analysis.qpt rename to src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt diff --git a/src/cplus_plugin/definitions/defaults.py b/src/cplus_plugin/definitions/defaults.py index 1914a7e56..bb676a353 100644 --- a/src/cplus_plugin/definitions/defaults.py +++ b/src/cplus_plugin/definitions/defaults.py @@ -139,7 +139,8 @@ ) # Default template file name -SCENARIO_ANALYSIS_TEMPLATE_NAME = "scenario_analysis.qpt" +SCENARIO_ANALYSIS_TEMPLATE_NAME = "scenario_analysis_default.qpt" +SCENARIO_ANALYSIS_METRICS_TEMPLATE_NAME = "scenario_analysis_metrics.qpt" SCENARIO_COMPARISON_TEMPLATE_NAME = "scenario_comparison.qpt" # Minimum sizes (in mm) for repeat items in the template diff --git a/src/cplus_plugin/lib/reports/generator.py b/src/cplus_plugin/lib/reports/generator.py index 67c5844b7..4fff229ae 100644 --- a/src/cplus_plugin/lib/reports/generator.py +++ b/src/cplus_plugin/lib/reports/generator.py @@ -44,6 +44,7 @@ from qgis.PyQt import QtCore, QtGui, QtXml from .comparison_table import ScenarioComparisonTableInfo +from ...conf import settings_manager from ...definitions.constants import ( ACTIVITY_GROUP_LAYER_NAME, ACTIVITY_WEIGHTED_GROUP_NAME, @@ -1043,6 +1044,10 @@ def __init__(self, context: ReportContext, feedback: QgsFeedback = None): self._area_processing_feedback = None self._activities_area = {} self._pixel_area_info = {} + self._use_custom_metrics = context.custom_metrics + self._metrics_configuration = None + if self._use_custom_metrics: + self._metrics_configuration = settings_manager.get_metric_configuration() if self._feedback: self._feedback.canceled.connect(self._on_feedback_cancelled) diff --git a/src/cplus_plugin/lib/reports/manager.py b/src/cplus_plugin/lib/reports/manager.py index b94c4b3d9..7faed201e 100644 --- a/src/cplus_plugin/lib/reports/manager.py +++ b/src/cplus_plugin/lib/reports/manager.py @@ -29,6 +29,7 @@ from ...definitions.defaults import ( DEFAULT_BASE_COMPARISON_REPORT_NAME, SCENARIO_ANALYSIS_TEMPLATE_NAME, + SCENARIO_ANALYSIS_METRICS_TEMPLATE_NAME, SCENARIO_COMPARISON_TEMPLATE_NAME, ) from ...models.base import Scenario, ScenarioResult @@ -275,7 +276,10 @@ def create_scenario_dir(self, scenario: Scenario) -> str: return scenario_path_str def generate( - self, scenario_result: ScenarioResult, feedback: QgsFeedback = None + self, + scenario_result: ScenarioResult, + feedback: QgsFeedback = None, + use_custom_metrics: bool = False, ) -> ReportSubmitStatus: """Initiates the report generation process using information resulting from the scenario analysis. @@ -287,6 +291,11 @@ def generate( If one is not specified then the manager will create one for the context. :type feedback: QgsFeedback + :param use_custom_metrics: True to use custom metrics else False. If + True and the metrics configuration is empty or undefined, then the + default activity table in the scenario analysis report will be used. + :type use_custom_metrics: bool + :returns: True if the report generation process was successfully submitted else False if a running process is re-submitted. Object also contains feedback object for report updating and cancellation. @@ -301,7 +310,7 @@ def generate( if feedback is None: feedback = QgsFeedback(self) - ctx = self.create_report_context(scenario_result, feedback) + ctx = self.create_report_context(scenario_result, feedback, use_custom_metrics) if ctx is None: log("Could not create report context. Check directory settings.") return ReportSubmitStatus(False, None, "") @@ -350,7 +359,10 @@ def report_result(self, scenario_id: str) -> typing.Union[ReportResult, None]: @classmethod def create_report_context( - cls, scenario_result: ScenarioResult, feedback: QgsFeedback + cls, + scenario_result: ScenarioResult, + feedback: QgsFeedback, + use_custom_metrics: bool = False, ) -> typing.Optional[ReportContext]: """Creates the report context for use in the report generator task. @@ -362,6 +374,11 @@ def create_report_context( application. :type feedback: QgsFeedback + :param use_custom_metrics: True to use custom metrics else False. If + True and the metrics configuration is empty or undefined, then the + default activity table in the scenario analysis report will be used. + :type use_custom_metrics: bool + :returns: A report context object containing the information for generating the report else None if it could not be created. :rtype: ReportContext @@ -408,7 +425,20 @@ def create_report_context( break counter += 1 - template_path = FileUtils.report_template_path(SCENARIO_ANALYSIS_TEMPLATE_NAME) + metrics_configuration = settings_manager.get_metric_configuration() + + if ( + use_custom_metrics + and metrics_configuration is not None + and metrics_configuration.is_valid() + ): + template_path = FileUtils.report_template_path( + SCENARIO_ANALYSIS_TEMPLATE_NAME + ) + else: + template_path = FileUtils.report_template_path( + SCENARIO_ANALYSIS_METRICS_TEMPLATE_NAME + ) return ReportContext( template_path=template_path, @@ -418,6 +448,7 @@ def create_report_context( project_file=project_file_path, feedback=feedback, output_layer_name=scenario_result.output_layer_name, + custom_metrics=use_custom_metrics, ) @classmethod diff --git a/src/cplus_plugin/models/report.py b/src/cplus_plugin/models/report.py index e1bb2f83b..8999d712d 100644 --- a/src/cplus_plugin/models/report.py +++ b/src/cplus_plugin/models/report.py @@ -30,6 +30,7 @@ class ReportContext(BaseReportContext): scenario: Scenario scenario_output_dir: str output_layer_name: str + custom_metrics: bool @dataclasses.dataclass diff --git a/src/cplus_plugin/utils.py b/src/cplus_plugin/utils.py index 19e8e09f7..b2a6d9873 100644 --- a/src/cplus_plugin/utils.py +++ b/src/cplus_plugin/utils.py @@ -487,7 +487,7 @@ def report_template_path(file_name=None) -> str: Caller needs to verify that the file actually exists. :param file_name: Template file name including the extension. If - none is specified then it will use `scenario_analysis.qpt` as the default + none is specified then it will use `scenario_analysis_default.qpt` as the default template name. :type file_name: str From 4a16c620b905b8f34537d687e1d2ddf1d8fd2128 Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 11 Nov 2024 18:05:44 +0300 Subject: [PATCH 22/47] Incorporate custom metrics report template. --- .../scenario_analysis_metrics.qpt | 2781 +++++++++++++++++ src/cplus_plugin/lib/reports/metrics.py | 115 +- src/cplus_plugin/main.py | 7 +- .../ui/activity_metrics_builder_dialog.ui | 70 +- 4 files changed, 2836 insertions(+), 137 deletions(-) create mode 100644 src/cplus_plugin/data/report_templates/scenario_analysis_metrics.qpt diff --git a/src/cplus_plugin/data/report_templates/scenario_analysis_metrics.qpt b/src/cplus_plugin/data/report_templates/scenario_analysis_metrics.qpt new file mode 100644 index 000000000..7f0e2a371 --- /dev/null +++ b/src/cplus_plugin/data/report_templates/scenario_analysis_metrics.qpt @@ -0,0 +1,2781 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + + + + +
+ + + + + + +
diff --git a/src/cplus_plugin/lib/reports/metrics.py b/src/cplus_plugin/lib/reports/metrics.py index 578e39a9d..1645b4af8 100644 --- a/src/cplus_plugin/lib/reports/metrics.py +++ b/src/cplus_plugin/lib/reports/metrics.py @@ -4,6 +4,7 @@ """ import inspect +import string import traceback import typing @@ -26,9 +27,14 @@ from ...definitions.defaults import BASE_PLUGIN_NAME from ...utils import FileUtils, function_help_to_html, log, open_documentation, tr +# Collection of metric expression functions +METRICS_LIBRARY = [] +# Variables VAR_ACTIVITY_AREA = "cplus_activity_area" VAR_ACTIVITY_NAME = "cplus_activity_name" + +# Function names FUNC_FINANCIAL_DUMMY = "dummy_financial_viability" @@ -95,75 +101,6 @@ def clone(self) -> "DummyFinancialComputation": return DummyFinancialComputation() -class TestExpressionFunction(QgsExpressionFunction): - """Python expression function""" - - def __init__( - self, - name, - group, - helptext="", - usesgeometry=False, - referenced_columns=None, - handlesnull=False, - params_as_list=False, - ): - # Call the parent constructor - QgsExpressionFunction.__init__(self, name, 0, group, helptext) - if referenced_columns is None: - referenced_columns = [QgsFeatureRequest.ALL_ATTRIBUTES] - self.params_as_list = params_as_list - self.usesgeometry = usesgeometry - self.referenced_columns = referenced_columns - self.handlesnull = handlesnull - - def usesGeometry(self, node): - return self.usesgeometry - - def referencedColumns(self, node): - return self.referenced_columns - - def handlesNull(self): - return self.handlesnull - - def isContextual(self): - return True - - def func(self, values, context, parent, node): - feature = None - if context: - feature = context.feature() - - try: - # Inspect the inner function signature to get the list of parameters - parameters = inspect.signature(self.function).parameters - kwvalues = {} - - # Handle special parameters - # those will not be inserted in the arguments list - # if they are present in the function signature - if "context" in parameters: - kwvalues["context"] = context - if "feature" in parameters: - kwvalues["feature"] = feature - if "parent" in parameters: - kwvalues["parent"] = parent - - # In this context, values is a list of the parameters passed to the expression. - # If self.params_as_list is True, values is passed as is to the inner function. - if self.params_as_list: - return self.function(values, **kwvalues) - # Otherwise (default), the parameters are expanded - return self.function(*values, **kwvalues) - - except Exception as ex: - tb = traceback.format_exception(None, ex, ex.__traceback__) - formatted_traceback = "".join(tb) - formatted_exception = f"{ex}:
{formatted_traceback}
" - parent.setEvalErrorString(formatted_exception) - return None - - def create_metrics_expression_scope() -> QgsExpressionContextScope: """Creates the expression context scope for activity metrics. @@ -204,45 +141,21 @@ def register_metric_functions(): """Register our custom functions with the expression engine.""" # QgsExpression.registerFunction(DummyFinancialComputation()) - register = True - name = FUNC_FINANCIAL_DUMMY - if register and QgsExpression.isFunctionName(name): - if not QgsExpression.unregisterFunction(name): - msgtitle = QCoreApplication.translate("UserExpressions", "User expressions") - msg = QCoreApplication.translate( - "UserExpressions", - "The user expression {0} already exists and could not be unregistered.", - ).format(name) - QgsMessageLog.logMessage(msg + "\n", msgtitle, Qgis.MessageLevel.Warning) - - group = "CPLUS" - helptext = "" - usesgeometry = False - referenced_columns = [QgsFeatureRequest.ALL_ATTRIBUTES] - handlesnull = False - f = TestExpressionFunction( - name, group, helptext, usesgeometry, referenced_columns, handlesnull, False - ) - QgsExpression.registerFunction(f, True) - - functions = QgsExpression.Functions() - idx = QgsExpression.functionIndex(name) - func = functions[idx] - log(f"Name: {func.name()}") - log(f"Groups: {str(func.groups())}") - # log(f"Help: {func.helpText()}") + f, name = _temp_get_func() + METRICS_LIBRARY.append(f) + QgsExpression.registerFunction(f) - func_prev = functions[idx - 2] - log(f"Name: {func_prev.name()}") - log(f"Groups: {str(func_prev.groups())}") - # log(f"Help: {func_prev.helpText()}") + # QgsExpression.unregisterFunction(FUNC_FINANCIAL_DUMMY) def unregister_metric_functions(): """Unregister the custom metric functions from the expression engine. """ - QgsExpression.unregisterFunction(FUNC_FINANCIAL_DUMMY) + func_names = [func.name() for func in METRICS_LIBRARY] + + for fn in func_names: + QgsExpression.unregisterFunction(fn) def create_metrics_expression_context( diff --git a/src/cplus_plugin/main.py b/src/cplus_plugin/main.py index 3c11aede7..1887e7961 100644 --- a/src/cplus_plugin/main.py +++ b/src/cplus_plugin/main.py @@ -261,7 +261,9 @@ def initGui(self): # Install report font self.install_report_font() - # register_metric_functions() + # Register metric functions. Note that these are + # scoped for specific contexts. + register_metric_functions() def onClosePlugin(self): """Cleanup necessary items here when plugin widget is closed.""" @@ -280,6 +282,9 @@ def unload(self): self.iface.unregisterOptionsWidgetFactory(self.reports_options_factory) self.iface.unregisterOptionsWidgetFactory(self.log_options_factory) + # Unregister metric functions + unregister_metric_functions() + except Exception as e: log(str(e), info=False) diff --git a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui index 1fc9c1b94..c494391ba 100644 --- a/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui +++ b/src/cplus_plugin/ui/activity_metrics_builder_dialog.ui @@ -6,7 +6,7 @@ 0 0 - 746 + 685 486
@@ -143,8 +143,8 @@ 0 0 - 728 - 395 + 667 + 382
@@ -176,20 +176,13 @@ QFrame::Raised - - - - <html><head/><body><p><span style=" font-weight:600;">Columns</span></p></body></html> - - - - - - - Remove column + + + + QAbstractItemView::NoEditTriggers - - ... + + QAbstractItemView::SelectRows @@ -206,10 +199,10 @@ - - + + - Move column one level up + Remove column ... @@ -226,26 +219,33 @@ - - - - QAbstractItemView::NoEditTriggers + + + + Move column one level down - - QAbstractItemView::SelectRows + + ... - - + + - Move column one level down + Move column one level up ... + + + + <html><head/><body><p><span style=" font-weight:600;">Columns</span></p></body></html> + + + @@ -302,6 +302,13 @@ + + + + <html><head/><body><p><span style=" font-style:italic; color:#ff0000;">*</span><span style=" font-style:italic;"> Required field</span></p></body></html> + + + @@ -322,13 +329,6 @@ - - - - <html><head/><body><p><span style=" font-style:italic; color:#ff0000;">*</span><span style=" font-style:italic;"> Required field</span></p></body></html> - - -
From 58035f42b177dc22019e413db08030117de4db8d Mon Sep 17 00:00:00 2001 From: Kahiu Date: Mon, 11 Nov 2024 18:09:24 +0300 Subject: [PATCH 23/47] Remove inapplicable report variable values. --- .../data/report_templates/scenario_analysis_default.qpt | 6 +++--- .../data/report_templates/scenario_analysis_metrics.qpt | 8 ++++---- .../data/report_templates/scenario_comparison.qpt | 8 ++++---- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt b/src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt index aa29d94e8..9c1ec7b00 100644 --- a/src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt +++ b/src/cplus_plugin/data/report_templates/scenario_analysis_default.qpt @@ -2487,12 +2487,12 @@ @@ -350,12 +356,10 @@ + @@ -392,10 +390,12 @@ @@ -1968,23 +1974,15 @@ + @@ -1992,10 +1990,12 @@