diff --git a/README.md b/README.md index 6224972..75c0077 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,7 @@ the one with the path `.venv\Scripts\python.exe`. This plugin is distributed under the terms of the [GNU General Public License, version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) license. See [LICENSE](LICENSE) for more information. + +### Attributations +Open icons created by Smashicons - Flaticon +Land use icons created by Fusion5085 - Flaticon diff --git a/arho_feature_template/core/exceptions.py b/arho_feature_template/core/exceptions.py new file mode 100644 index 0000000..50b61b5 --- /dev/null +++ b/arho_feature_template/core/exceptions.py @@ -0,0 +1,2 @@ +class UnexpectedNoneError(Exception): + """Internal QGIS errors that should not be happened""" diff --git a/arho_feature_template/core/new_plan.py b/arho_feature_template/core/new_plan.py new file mode 100644 index 0000000..97dd5ee --- /dev/null +++ b/arho_feature_template/core/new_plan.py @@ -0,0 +1,63 @@ +from qgis.core import QgsProject, QgsVectorLayer +from qgis.utils import iface + +from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan + + +class NewPlan: + def add_new_plan(self): + # Filtered layers are not editable, so clear filters first. + self.clear_all_filters() + + layers = QgsProject.instance().mapLayersByName("Kaava") + if not layers: + iface.messageBar().pushMessage("Error", "Layer 'Kaava' not found", level=3) + return + + kaava_layer = layers[0] + + if not kaava_layer.isEditable(): + kaava_layer.startEditing() + + iface.setActiveLayer(kaava_layer) + + iface.actionAddFeature().trigger() + + # Connect the featureAdded signal + kaava_layer.featureAdded.connect(self.feature_added) + + def feature_added(self): + kaava_layer = iface.activeLayer() + kaava_layer.featureAdded.disconnect() + feature_ids_before_commit = kaava_layer.allFeatureIds() + if kaava_layer.isEditable(): + if not kaava_layer.commitChanges(): + iface.messageBar().pushMessage("Error", "Failed to commit changes to the layer.", level=3) + return + else: + iface.messageBar().pushMessage("Error", "Layer is not editable.", level=3) + return + + feature_ids_after_commit = kaava_layer.allFeatureIds() + + # Find the new plan.id by comparing fids before and after commit. + new_feature_id = next((fid for fid in feature_ids_after_commit if fid not in feature_ids_before_commit), None) + + if new_feature_id is not None: + new_feature = kaava_layer.getFeature(new_feature_id) + + if new_feature.isValid(): + feature_id_value = new_feature["id"] + update_selected_plan(LandUsePlan(feature_id_value)) + else: + iface.messageBar().pushMessage("Error", "Invalid feature retrieved.", level=3) + else: + iface.messageBar().pushMessage("Error", "No new feature was added.", level=3) + + def clear_all_filters(self): + """Clear filters for all vector layers in the project.""" + layers = QgsProject.instance().mapLayers().values() + + for layer in layers: + if isinstance(layer, QgsVectorLayer): + layer.setSubsetString("") diff --git a/arho_feature_template/core/update_plan.py b/arho_feature_template/core/update_plan.py new file mode 100644 index 0000000..d083090 --- /dev/null +++ b/arho_feature_template/core/update_plan.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass + +from qgis.core import QgsMapLayer, QgsProject, QgsVectorLayer +from qgis.utils import iface + + +# To be extended and moved +@dataclass +class LandUsePlan: + id: str + + +# To be replaced later +LAYER_PLAN_ID_MAP = { + "Kaava": "id", + "Maankäytön kohteet": "plan_id", + "Muut pisteet": "plan_id", + "Viivat": "plan_id", + "Aluevaraus": "plan_id", + "Osa-alue": "plan_id", +} + + +def update_selected_plan(new_plan: LandUsePlan): + """Update the project layers based on the selected land use plan.""" + plan_id = new_plan.id + + for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): + # Set the filter on each layer using the plan_id + set_filter_for_vector_layer(layer_name, field_name, plan_id) + + +def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): + """Set a filter for the given vector layer.""" + layers = QgsProject.instance().mapLayersByName(layer_name) + + if not _check_layer_count(layers): + return + + layer = layers[0] + + expression = f"\"{field_name}\" = '{field_value}'" + + # Apply the filter to the layer + if not layer.setSubsetString(expression): + iface.messageBar().pushMessage("Error", f"Failed to filter layer {layer_name} with query {expression}", level=3) + + +def _check_layer_count(layers: list) -> bool: + """Check if any layers are returned.""" + if not layers: + iface.messageBar().pushMessage("Error", "ERROR: No layers found with the specified name.", level=3) + return False + return True + + +def _check_vector_layer(layer: QgsMapLayer) -> bool: + """Check if the given layer is a vector layer.""" + if not isinstance(layer, QgsVectorLayer): + iface.messageBar().pushMessage("Error", f"Layer {layer.name()} is not a vector layer: {type(layer)}", level=3) + return False + return True diff --git a/arho_feature_template/gui/load_plan_dialog.py b/arho_feature_template/gui/load_plan_dialog.py new file mode 100644 index 0000000..8cbf984 --- /dev/null +++ b/arho_feature_template/gui/load_plan_dialog.py @@ -0,0 +1,145 @@ +from importlib import resources + +from qgis.core import QgsProviderRegistry +from qgis.PyQt import uic +from qgis.PyQt.QtCore import QRegularExpression, QSortFilterProxyModel, Qt +from qgis.PyQt.QtGui import QStandardItem, QStandardItemModel +from qgis.PyQt.QtWidgets import QComboBox, QDialog, QDialogButtonBox, QLineEdit, QMessageBox, QPushButton, QTableView + +from arho_feature_template.core.exceptions import UnexpectedNoneError + +ui_path = resources.files(__package__) / "load_plan_dialog.ui" + +LoadPlanDialogBase, _ = uic.loadUiType(ui_path) + + +class PlanFilterProxyModel(QSortFilterProxyModel): + def filterAcceptsRow(self, source_row, source_parent): # noqa: N802 + model = self.sourceModel() + if not model: + return False + + filter_text = self.filterRegularExpression().pattern() + if not filter_text: + return True + + for column in range(5): + index = model.index(source_row, column, source_parent) + data = model.data(index) + if data and filter_text.lower() in data.lower(): + return True + + return False + + +class LoadPlanDialog(QDialog, LoadPlanDialogBase): # type: ignore + connectionComboBox: QComboBox # noqa: N815 + push_button_load: QPushButton + planTableView: QTableView # noqa: N815 + searchLineEdit: QLineEdit # noqa: N815 + buttonBox: QDialogButtonBox # noqa: N815 + + def __init__(self, parent, connections): + super().__init__(parent) + self.setupUi(self) + + self._selected_plan_id = None + + self.buttonBox.rejected.connect(self.reject) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) + + self.push_button_load.clicked.connect(self.load_plans) + self.searchLineEdit.textChanged.connect(self.filter_plans) + + self.connectionComboBox.addItems(connections) + + self.planTableView.setSelectionMode(QTableView.SingleSelection) + self.planTableView.setSelectionBehavior(QTableView.SelectRows) + self.planTableView.selectionModel().selectionChanged.connect(self.on_selection_changed) + + self.model = QStandardItemModel() + self.model.setColumnCount(5) + self.model.setHorizontalHeaderLabels( + [ + "ID", + "Tuottajan kaavatunnus", + "Nimi", + "Kaavan elinkaaren tila", + "Kaavalaji", + ] + ) + + self.filterProxyModel = PlanFilterProxyModel() + self.filterProxyModel.setSourceModel(self.model) + self.filterProxyModel.setFilterCaseSensitivity(Qt.CaseInsensitive) + + self.planTableView.setModel(self.filterProxyModel) + + def load_plans(self): + self.model.removeRows(0, self.model.rowCount()) + + selected_connection = self.connectionComboBox.currentText() + if not selected_connection: + self.planTableView.setModel(QStandardItemModel()) + return + + provider_registry = QgsProviderRegistry.instance() + if provider_registry is None: + raise UnexpectedNoneError + postgres_provider_metadata = provider_registry.providerMetadata("postgres") + if postgres_provider_metadata is None: + raise UnexpectedNoneError + + try: + connection = postgres_provider_metadata.createConnection(selected_connection) + plans = connection.executeSql(""" + SELECT + p.id, + p.producers_plan_identifier, + p.name ->> 'fin' AS name_fin, + l.name ->> 'fin' AS lifecycle_status_fin, + pt.name ->> 'fin' AS plan_type_fin + FROM + hame.plan p + LEFT JOIN + codes.lifecycle_status l + ON + p.lifecycle_status_id = l.id + LEFT JOIN + codes.plan_type pt + ON + p.plan_type_id = pt.id; + """) + for plan in plans: + self.model.appendRow([QStandardItem(column) for column in plan]) + + except Exception as e: # noqa: BLE001 + QMessageBox.critical(self, "Error", f"Failed to load plans: {e}") + self.model.removeRows(0, self.model.rowCount()) + + def filter_plans(self): + search_text = self.searchLineEdit.text() + if search_text: + search_regex = QRegularExpression(search_text) + self.filterProxyModel.setFilterRegularExpression(search_regex) + else: + self.filterProxyModel.setFilterRegularExpression("") + + def on_selection_changed(self): + # Enable the OK button only if a row is selected + selection = self.planTableView.selectionModel().selectedRows() + ok_button = self.buttonBox.button(QDialogButtonBox.Ok) + if selection: + selected_row = selection[0].row() + self._selected_plan_id = self.planTableView.model().index(selected_row, 0).data() + ok_button.setEnabled(True) + else: + self._selected_plan_id = None + ok_button.setEnabled(False) + + def get_selected_connection(self): + return self.connectionComboBox.currentText() + + def get_selected_plan_id(self): + return self._selected_plan_id diff --git a/arho_feature_template/gui/load_plan_dialog.ui b/arho_feature_template/gui/load_plan_dialog.ui new file mode 100644 index 0000000..0a0c82d --- /dev/null +++ b/arho_feature_template/gui/load_plan_dialog.ui @@ -0,0 +1,147 @@ + + + LoadPlanDialog + + + + 0 + 0 + 800 + 600 + + + + + 800 + 600 + + + + Avaa kaava + + + + + + Valitse tietokantayhteys: + + + + + + + + + + 0 + 0 + + + + + + + + Lataa kaavat + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 0 + 0 + + + + + + + + Etsi kaavoja: + + + + + + + + 0 + 0 + + + + Etsi kaavoja... + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 0 + 0 + + + + + + + + Kaavat: + + + + + + + + 1 + 1 + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 0 + 0 + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/arho_feature_template/gui/plan_regulation_group_widget.py b/arho_feature_template/gui/plan_regulation_group_widget.py index 96e5784..916bcbb 100644 --- a/arho_feature_template/gui/plan_regulation_group_widget.py +++ b/arho_feature_template/gui/plan_regulation_group_widget.py @@ -6,6 +6,7 @@ from qgis.core import QgsApplication from qgis.PyQt import uic +from qgis.PyQt.QtCore import pyqtSignal from qgis.PyQt.QtGui import QFont, QIcon from qgis.PyQt.QtWidgets import QLabel, QLineEdit, QWidget @@ -24,6 +25,8 @@ class PlanRegulationGroupWidget(QWidget, FormClass): # type: ignore """A widget representation of a plan regulation group.""" + delete_signal = pyqtSignal(QWidget) + def __init__(self, feature: Feature): super().__init__() self.setupUi(self) @@ -62,16 +65,20 @@ def __init__(self, feature: Feature): if child.layer == "plan_requlation": self.create_widgets_for_plan_regulation(child) + def request_delete(self): + self.delete_signal.emit(self) + def init_buttons(self): self.conf_btn.setIcon(QIcon(plugin_path("resources", "icons", "settings.svg"))) self.del_btn.setIcon(QgsApplication.getThemeIcon("mActionDeleteSelected.svg")) + self.del_btn.clicked.connect(self.request_delete) def create_widgets_for_plan_regulation(self, plan_regulation_feature: Feature): row = self.plan_regulation_grid_layout.rowCount() + 1 for plan_regulation_config in plan_regulation_feature.attributes: if plan_regulation_config.attribute == "type_of_plan_regulation_id": id_label = QLabel(plan_regulation_config.display()) - print(plan_regulation_config) + # print(plan_regulation_config) self.plan_regulation_grid_layout.addWidget(id_label, row, 0) elif plan_regulation_config.attribute == "numeric_default": if not self.input_value_header: diff --git a/arho_feature_template/gui/template_attribute_form.py b/arho_feature_template/gui/template_attribute_form.py index e73b257..23b10e7 100644 --- a/arho_feature_template/gui/template_attribute_form.py +++ b/arho_feature_template/gui/template_attribute_form.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: from qgis.PyQt.QtWidgets import QWidget - from arho_feature_template.core.template_library_config import FeatureTemplate + from arho_feature_template.core.template_library_config import Feature, FeatureTemplate ui_path = resources.files(__package__) / "template_attribute_form.ui" FormClass, _ = uic.loadUiType(ui_path) @@ -47,31 +47,56 @@ def __init__(self, feature_template_config: FeatureTemplate): self.button_box.accepted.connect(self._on_ok_clicked) # INIT + self.scroll_area_spacer = None + self.available_plan_regulation_group_configs: list[Feature] = [] + self.setWindowTitle(feature_template_config.name) self.init_plan_regulation_groups(feature_template_config) + self.init_add_plan_regulation_group_btn() + + def add_spacer(self): self.scroll_area_spacer = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Expanding) self.plan_regulation_group_scrollarea_contents.layout().addItem(self.scroll_area_spacer) - self.init_add_plan_regulation_group_btn() + + def remove_spacer(self): + if self.scroll_area_spacer is not None: + self.plan_regulation_group_scrollarea_contents.layout().removeItem(self.scroll_area_spacer) + self.scroll_area_spacer = None + + def add_plan_regulation_group(self, feature_config: Feature): + new_plan_regulation_group = PlanRegulationGroupWidget(feature_config) + new_plan_regulation_group.delete_signal.connect(self.remove_plan_regulation_group) + self.remove_spacer() + self.plan_regulation_group_scrollarea_contents.layout().addWidget(new_plan_regulation_group) + self.add_spacer() + + def remove_plan_regulation_group(self, plan_regulation_group_widget: PlanRegulationGroupWidget): + self.plan_regulation_group_scrollarea_contents.layout().removeWidget(plan_regulation_group_widget) + plan_regulation_group_widget.deleteLater() def init_add_plan_regulation_group_btn(self): menu = QMenu() - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 1") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 2") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 3") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 4") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 5") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 6") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 7") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 8") - self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 9") + for config in self.available_plan_regulation_group_configs: + plan_regulation_group_name = "" + for attribute in config.attributes: + if attribute.attribute == "name": + plan_regulation_group_name = attribute.display() + + action = menu.addAction(plan_regulation_group_name) + action.triggered.connect(lambda _, config=config: self.add_plan_regulation_group(config)) self.add_plan_regulation_group_btn.setMenu(menu) def init_plan_regulation_groups(self, feature_template_config: FeatureTemplate): + if feature_template_config.feature.child_features is None: + return for child_feature in feature_template_config.feature.child_features: if child_feature.layer == "plan_requlation_group": - plan_regulation_group_entry = PlanRegulationGroupWidget(child_feature) - self.plan_regulation_group_scrollarea_contents.layout().addWidget(plan_regulation_group_entry) + # Collect encountered plan regulation groups in init + # This does not need to be done if Katja config file is read beforehand and + # that handles available plan regulation groups + self.available_plan_regulation_group_configs.append(child_feature) + self.add_plan_regulation_group(child_feature) else: # TODO print(f"Encountered child feature with unrecognized layer: {child_feature.layer}") diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index f06d83c..fc34cc2 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -1,16 +1,22 @@ from __future__ import annotations +import os from typing import TYPE_CHECKING, Callable, cast from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction, QWidget +from qgis.PyQt.QtWidgets import QAction, QDialog, QMessageBox, QWidget from qgis.utils import iface from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool +from arho_feature_template.core.new_plan import NewPlan +from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan +from arho_feature_template.gui.load_plan_dialog import LoadPlanDialog from arho_feature_template.qgis_plugin_tools.tools.custom_logging import setup_logger, teardown_logger from arho_feature_template.qgis_plugin_tools.tools.i18n import setup_translation from arho_feature_template.qgis_plugin_tools.tools.resources import plugin_name +from arho_feature_template.utils.db_utils import get_existing_database_connection_names +from arho_feature_template.utils.misc_utils import PLUGIN_PATH if TYPE_CHECKING: from qgis.gui import QgisInterface, QgsMapTool @@ -25,6 +31,7 @@ class Plugin: def __init__(self) -> None: setup_logger(Plugin.name) + self.digitizing_tool = None # initialize locale locale, file_path = setup_translation() @@ -120,12 +127,17 @@ def add_action( def initGui(self) -> None: # noqa N802 self.templater = FeatureTemplater() + self.new_plan = NewPlan() + + plan_icon_path = os.path.join(PLUGIN_PATH, "resources/icons/city.png") # A placeholder icon + load_icon_path = os.path.join(PLUGIN_PATH, "resources/icons/folder.png") # A placeholder icon iface.addDockWidget(Qt.RightDockWidgetArea, self.templater.template_dock) self.templater.template_dock.visibilityChanged.connect(self.dock_visibility_changed) iface.mapCanvas().mapToolSet.connect(self.templater.digitize_map_tool.deactivate) + # Add main plugin action to the toolbar self.template_dock_action = self.add_action( "", "Feature Templates", @@ -136,10 +148,52 @@ def initGui(self) -> None: # noqa N802 add_to_toolbar=True, ) + self.new_land_use_plan_action = self.add_action( + plan_icon_path, + "Create New Land Use Plan", + self.add_new_plan, + add_to_menu=True, + add_to_toolbar=True, + status_tip="Create a new land use plan", + ) + + self.load_land_use_plan_action = self.add_action( + load_icon_path, + text="Load existing land use plan", + triggered_callback=self.load_existing_land_use_plan, + parent=iface.mainWindow(), + add_to_toolbar=True, + ) + def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> None: # noqa: ARG002 if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) + def add_new_plan(self): + self.new_plan.add_new_plan() + + def load_existing_land_use_plan(self) -> None: + """Open existing land use plan.""" + + connections = get_existing_database_connection_names() + + if not connections: + QMessageBox.critical(None, "Error", "No database connections found.") + return + + dialog = LoadPlanDialog(None, connections) + + if dialog.exec_() == QDialog.Accepted: + selected_plan_id = dialog.get_selected_plan_id() + + if not selected_plan_id: + QMessageBox.critical(None, "Error", "No plan was selected.") + return + + plan = LandUsePlan(selected_plan_id) + + update_selected_plan(plan) + def unload(self) -> None: """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: diff --git a/arho_feature_template/resources/icons/city.png b/arho_feature_template/resources/icons/city.png new file mode 100644 index 0000000..ed82aaf Binary files /dev/null and b/arho_feature_template/resources/icons/city.png differ diff --git a/arho_feature_template/resources/icons/folder.png b/arho_feature_template/resources/icons/folder.png new file mode 100644 index 0000000..9eb2915 Binary files /dev/null and b/arho_feature_template/resources/icons/folder.png differ diff --git a/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml b/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml index 35c7aef..b6cf67f 100644 --- a/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml +++ b/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml @@ -7,6 +7,7 @@ templates: description: Kaavakohde ilman kikkareita feature: layer: land_use_area + # layer: Aluevaraus attributes: - attribute: name - attribute: type_of_underground_id @@ -15,6 +16,7 @@ templates: description: Aluella kuvataan ... feature: layer: land_use_area + # layer: Aluevaraus attributes: - attribute: name - attribute: type_of_underground_id diff --git a/arho_feature_template/utils/__init__.py b/arho_feature_template/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arho_feature_template/utils/db_utils.py b/arho_feature_template/utils/db_utils.py new file mode 100644 index 0000000..b8f67a0 --- /dev/null +++ b/arho_feature_template/utils/db_utils.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import logging + +from qgis.core import QgsProviderRegistry + +from arho_feature_template.core.exceptions import UnexpectedNoneError + +LOGGER = logging.getLogger("LandUsePlugin") + + +def get_existing_database_connection_names() -> list[str]: + """ + Retrieve the list of existing database connections from QGIS settings. + + :return: A set of available PostgreSQL connection names. + """ + + provider_registry = QgsProviderRegistry.instance() + if provider_registry is None: + raise UnexpectedNoneError + postgres_provider_metadata = provider_registry.providerMetadata("postgres") + if postgres_provider_metadata is None: + raise UnexpectedNoneError + + return list(postgres_provider_metadata.dbConnections(False)) diff --git a/arho_feature_template/utils/misc_utils.py b/arho_feature_template/utils/misc_utils.py new file mode 100644 index 0000000..42f38d6 --- /dev/null +++ b/arho_feature_template/utils/misc_utils.py @@ -0,0 +1,3 @@ +import os + +PLUGIN_PATH = os.path.dirname(os.path.dirname(__file__))