From 78918cf7d7b97b073bef1c1d5c3988c127c1a068 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Mon, 7 Oct 2024 14:28:57 +0300 Subject: [PATCH 01/12] Create new land use plan WIP --- arho_feature_template/LandUsePlanDialog.py | 52 +++++++++ arho_feature_template/SimpleDrawTool.py | 16 +++ .../access_violation_plugin.txt | 58 ++++++++++ arho_feature_template/plugin.py | 84 +++++++++++++- .../resources/icons/city.png | Bin 0 -> 25010 bytes .../resources/icons/folder.png | Bin 0 -> 19141 bytes arho_feature_template/update_plan.py | 105 ++++++++++++++++++ arho_feature_template/utils/misc_utils.py | 3 + 8 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 arho_feature_template/LandUsePlanDialog.py create mode 100644 arho_feature_template/SimpleDrawTool.py create mode 100644 arho_feature_template/access_violation_plugin.txt create mode 100644 arho_feature_template/resources/icons/city.png create mode 100644 arho_feature_template/resources/icons/folder.png create mode 100644 arho_feature_template/update_plan.py create mode 100644 arho_feature_template/utils/misc_utils.py diff --git a/arho_feature_template/LandUsePlanDialog.py b/arho_feature_template/LandUsePlanDialog.py new file mode 100644 index 0000000..e1ac784 --- /dev/null +++ b/arho_feature_template/LandUsePlanDialog.py @@ -0,0 +1,52 @@ +from qgis.PyQt.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QComboBox + +class LandUsePlanDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Define Land Use Plan") + + self.layout = QVBoxLayout() + + # ToDo! The id should be automatically set to next available id!! + self.id_label = QLabel("Plan ID:") + self.id_input = QLineEdit(self) + self.layout.addWidget(self.id_label) + self.layout.addWidget(self.id_input) + + # Input for custom name + self.name_label = QLabel("Plan Name:") + self.name_input = QLineEdit(self) + self.layout.addWidget(self.name_label) + self.layout.addWidget(self.name_input) + + # Plan type selection + self.type_label = QLabel("Plan Type:") + self.type_combo = QComboBox(self) + self.type_combo.addItems(["Asemakaava", "Maakuntakaava", "Yleiskaava"]) + self.layout.addWidget(self.type_label) + self.layout.addWidget(self.type_combo) + + self.submit_button = QPushButton("Submit", self) + self.submit_button.clicked.connect(self.on_submit) + self.layout.addWidget(self.submit_button) + + self.setLayout(self.layout) + + self.plan_id = None + self.plan_name = None + self.plan_type = None + + # Maybe the template should be read here and allow setting values in this dialog? + # So open additional fields in the dialog based on the type of plan selected. + + def on_submit(self): + self.plan_id = self.id_input.text() + self.plan_name = self.name_input.text() + # ToDo! plan_type should define which template is used. + self.plan_type = self.type_combo.currentText() + + if not self.plan_id or not self.plan_name: + QMessageBox.warning(self, "Input Error", "Both ID and Name fields must be filled out.") + return + + self.accept() \ No newline at end of file diff --git a/arho_feature_template/SimpleDrawTool.py b/arho_feature_template/SimpleDrawTool.py new file mode 100644 index 0000000..e3bfddb --- /dev/null +++ b/arho_feature_template/SimpleDrawTool.py @@ -0,0 +1,16 @@ +from qgis.gui import QgsMapToolDigitizeFeature, QgsMapToolCapture +from qgis.core import QgsProject +from qgis.utils import iface + +class SimpleDrawTool(QgsMapToolDigitizeFeature): + def __init__(self, canvas): + # Properly initializing with the required parameters + super().__init__(canvas, None, QgsMapToolCapture.CapturePolygon) + + self.canvas = canvas + self.layer = QgsProject.instance().mapLayersByName("Kaava")[0] + iface.setActiveLayer(self.layer) + + def addFeature(self, f): + self.layer.addFeature(f) + self.layer.triggerRepaint() \ No newline at end of file diff --git a/arho_feature_template/access_violation_plugin.txt b/arho_feature_template/access_violation_plugin.txt new file mode 100644 index 0000000..7c76836 --- /dev/null +++ b/arho_feature_template/access_violation_plugin.txt @@ -0,0 +1,58 @@ +from qgis.gui import QgsDockWidget, QgsMapToolDigitizeFeature, QgsAdvancedDigitizingDockWidget, QgsMapToolCapture + +def start_digitizing(self) -> None: + """Create a new QGIS project, add a layer group, create a new empty vector layer for digitizing, and add an OSM layer.""" + + # 1. Create a new project + project = QgsProject.instance() + project.clear() # Clear the current project (like starting a new one) + + # 2. Create a new group in the layer tree + group_name = "uusi kaava" + root = project.layerTreeRoot() + group = root.addGroup(group_name) + + # 3. Create a new empty vector layer (polygon layer for digitizing land use areas) + layer_name = "New Land Use Plan" + crs = "EPSG:4326" # Define the CRS (EPSG:4326 is WGS84, you can use any appropriate CRS) + new_layer = QgsVectorLayer(f"Polygon?crs={crs}", layer_name, "memory") + + if not new_layer.isValid(): + iface.messageBar().pushMessage("Error", "Failed to create new layer", level=3) + return + + # 4. Add the new layer to the project inside the "uusi kaava" group + QgsProject.instance().addMapLayer(new_layer, False) # Add layer to project but don't display it yet + group.insertLayer(0, new_layer) # Insert layer into the group at the first position + + # 5. Set the newly created layer as the active layer so digitizing can start + iface.setActiveLayer(new_layer) + + # 6. Start editing the layer (so the user can begin digitizing) + new_layer.startEditing() + + # 7. Add OpenStreetMap layer + osm_url = "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png" + osm_layer = QgsRasterLayer(osm_url, "OpenStreetMap", "wms") + + if not osm_layer.isValid(): + iface.messageBar().pushMessage("Error", "Failed to add OpenStreetMap layer", level=3) + return + + # Add the OSM layer to the project + QgsProject.instance().addMapLayer(osm_layer) + + # 8. Create an instance of the advanced digitizing dock widget + advanced_digitizing_dock_widget = QgsAdvancedDigitizingDockWidget(iface.mapCanvas(), None) + + # 9. Create the digitizing tool + digitizing_tool = QgsMapToolDigitizeFeature(iface.mapCanvas(), advanced_digitizing_dock_widget, QgsMapToolCapture.CapturePolygon) + + # Ensure the layer is set correctly + digitizing_tool.setLayer(new_layer) # Set the layer to digitize + + # Set the map tool + iface.mapCanvas().setMapTool(digitizing_tool) + + # Notify the user that digitizing is ready + iface.messageBar().pushMessage("Info", f"New project created. Layer '{layer_name}' is ready for digitizing, and OpenStreetMap layer has been added.", level=0) \ No newline at end of file diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index f06d83c..99d06f6 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -1,10 +1,13 @@ from __future__ import annotations +import os + from typing import TYPE_CHECKING, Callable, cast -from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator +from qgis.core import QgsProject +from qgis.PyQt.QtCore import QCoreApplication, QTranslator, Qt from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction, QWidget +from qgis.PyQt.QtWidgets import QAction, QWidget, QDialog from qgis.utils import iface from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool @@ -12,6 +15,10 @@ 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.misc_utils import PLUGIN_PATH +from arho_feature_template.update_plan import update_selected_plan, _check_vector_layer, LandUsePlan +from arho_feature_template.LandUsePlanDialog import LandUsePlanDialog + if TYPE_CHECKING: from qgis.gui import QgisInterface, QgsMapTool @@ -25,6 +32,7 @@ class Plugin: def __init__(self) -> None: setup_logger(Plugin.name) + self.digitizing_tool = None # initialize locale locale, file_path = setup_translation() @@ -121,11 +129,17 @@ def add_action( def initGui(self) -> None: # noqa N802 self.templater = FeatureTemplater() + new_path = os.path.join(PLUGIN_PATH, "resources/icons/city.png") # A placeholder icon + # Land use icons created by Fusion5085 - Flaticon + load_path = os.path.join(PLUGIN_PATH, "resources/icons/folder.png") + # Open icons created by Smashicons - Flaticon + 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 +150,74 @@ def initGui(self) -> None: # noqa N802 add_to_toolbar=True, ) + # Add additional action for "Lisää kaava" + new_action = self.add_action( + new_path, + text="Lisää kaava", + triggered_callback=self.start_digitizing, + parent=iface.mainWindow(), # Use iface instead of self.iface + add_to_toolbar=True, + ) + + # Add additional action for "Avaa kaava" + load_action = self.add_action( + load_path, + text="Avaa kaava", + triggered_callback=self.open_plan, + parent=iface.mainWindow(), # Use iface instead of self.iface + 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 start_digitizing(self) -> None: + dialog = LandUsePlanDialog() + if dialog.exec_() != QDialog.Accepted: + return + + # Create the LandUsePlan object from user input + new_plan = LandUsePlan(id=dialog.plan_id, name=dialog.plan_name) + plan_type = dialog.plan_type + + update_selected_plan(new_plan) + + layer_name = "Kaava" + + layers = QgsProject.instance().mapLayersByName(layer_name) + kaava_layer = layers[0] + + if not kaava_layer: + iface.messageBar().pushMessage("Error", f"Layer '{layer_name}' not found in the project", level=3) + return + + # new_layer = layers[0] + + if not _check_vector_layer(kaava_layer): + iface.messageBar().pushMessage("Error", f"Layer '{layer_name}' is not a valid vector layer", level=3) + return + + iface.setActiveLayer(kaava_layer) + if not kaava_layer.isEditable(): + kaava_layer.startEditing() + + # digitizing_tool = SimpleDrawTool(iface.mapCanvas()) + + # Set the custom draw tool as the active tool on the canvas + # iface.mapCanvas().setMapTool(digitizing_tool) + + # Notify the user + iface.messageBar().pushMessage( + "Info", + f"Layer '{layer_name}' is filtered by plan '{new_plan.name}' of type '{plan_type}' and ready for digitizing.", + level=0, + ) + + def open_plan(self) -> None: + """Open existing land use plan.""" + pass + def unload(self) -> None: """Removes the plugin menu item and icon from QGIS GUI.""" for action in self.actions: @@ -153,4 +231,4 @@ def dock_visibility_changed(self, visible: bool) -> None: # noqa: FBT001 self.template_dock_action.setChecked(visible) def toggle_template_dock(self, show: bool) -> None: # noqa: FBT001 - self.templater.template_dock.setUserVisible(show) + self.templater.template_dock.setUserVisible(show) \ No newline at end of file diff --git a/arho_feature_template/resources/icons/city.png b/arho_feature_template/resources/icons/city.png new file mode 100644 index 0000000000000000000000000000000000000000..ed82aaf6f5506ed7f2a30b29ea1266015f5189a4 GIT binary patch literal 25010 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 literal 0 HcmV?d00001 diff --git a/arho_feature_template/resources/icons/folder.png b/arho_feature_template/resources/icons/folder.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb2915ff672c769e4dadec99bd63330629f4f28 GIT binary patch literal 19141 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 literal 0 HcmV?d00001 diff --git a/arho_feature_template/update_plan.py b/arho_feature_template/update_plan.py new file mode 100644 index 0000000..7311453 --- /dev/null +++ b/arho_feature_template/update_plan.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass + +from typing import List + +from qgis.core import QgsProject, QgsVectorLayer, QgsMapLayer + + +# To be extended and moved +@dataclass +class LandUsePlan: + id: str + name: str + + +# To be replaced later +LAYER_PLAN_ID_MAP = { + "Kaava": "id", + "land_use_area": "plan_id", + "Osa-alue": "plan_id", +} + +def update_selected_plan(new_plan: LandUsePlan): + """Update the project layers based on the selected land use plan.""" + id = new_plan.id + for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): + set_filter_for_vector_layer(layer_name, field_name, id) + + +def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): + """Get the layer by name, check validity, and apply a filter based on the field value.""" + # Get the layer(s) by name + layers = QgsProject.instance().mapLayersByName(layer_name) + + # Ensure at least one layer was found + if not _check_layer_count(layers): + return + + # Access the first layer from the list + layer = layers[0] + + # Check if the layer is a valid vector layer + if not _check_vector_layer(layer): + return + + # Apply filtering logic here since the layer is valid + print(f"Layer {layer.name()} is valid for filtering.") + # Further operations like setting filter or using this layer for processing + +def _check_layer_count(layers: list) -> bool: + """Check if any layers are returned.""" + if not layers: + print(f"ERROR: No layers found with the specified name.") + 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): + print(f"ERROR: Layer {layer.name()} is not a vector layer: {type(layer)}") + return False + return True + +# def update_selected_plan(new_plan: LandUsePlan): + # id = new_plan.id + # for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): + # set_filter_for_vector_layer(layer_name, field_name, id) + + +# def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): + # Get layer and perform checks + # layers = QgsProject.instance().mapLayersByName(layer_name) + # if not _check_layer_count(layers): + # return + # layer = layers[0] + # if not _check_vector_layer(layer): + # return + + # Check if the layer is empty + # if layer.featureCount() == 0: + # print(f"INFO: Layer '{layer_name}' is empty. No features to filter.") + # return # Simply return without applying any filter + + # else: + # Perform the filtering + # query = f"{field_name} = {field_value}" + # if not layer.setSubsetString(query): + # TODO: Convert to log msg? + # print(f"ERROR: Failed to filter layer {layer_name} with query {query}") + +# def _check_layer_count(layers: List[QgsMapLayer]) -> bool: + # if len(layers) > 1: + # TODO: Convert to log msg? + # print(f"ERROR: Found multiple layers ({len(layers)}) with same name ({layers[0].name()}).") + # return False + # return True + + +# def _check_vector_layer(layer: QgsMapLayer) -> bool: + # if not isinstance(layer, QgsVectorLayer): + # TODO: Convert to log msg? + # print(f"ERROR: Layer {layer.name()} is not a vector layer: f{type(layer)}") + # print(f"Error this is not a vector layer! {layer}") + # return False + # return True \ No newline at end of file diff --git a/arho_feature_template/utils/misc_utils.py b/arho_feature_template/utils/misc_utils.py new file mode 100644 index 0000000..3e5a741 --- /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__)) \ No newline at end of file From 65ddd60cc74e9aa988930dfe61c44698f9ab0339 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Wed, 9 Oct 2024 10:55:18 +0300 Subject: [PATCH 02/12] Added "Add new land use plan"-button. - Added "Add new land use plan"-button. - Button opens new_land_use_plan_form -> filters layers -> starts digitizing -> creates feature with digitized geometry and values from the form. --- README.md | 4 + arho_feature_template/LandUsePlanDialog.py | 52 --------- arho_feature_template/SimpleDrawTool.py | 16 --- .../access_violation_plugin.txt | 58 ---------- .../core/create_land_use_plan.py | 65 +++++++++++ arho_feature_template/core/update_plan.py | 53 +++++++++ .../gui/new_land_use_plan_form.py | 37 ++++++ .../gui/new_land_use_plan_form.ui | 107 ++++++++++++++++++ arho_feature_template/plugin.py | 99 ++++++---------- arho_feature_template/update_plan.py | 105 ----------------- arho_feature_template/utils/__init__.py | 0 arho_feature_template/utils/misc_utils.py | 2 +- 12 files changed, 304 insertions(+), 294 deletions(-) delete mode 100644 arho_feature_template/LandUsePlanDialog.py delete mode 100644 arho_feature_template/SimpleDrawTool.py delete mode 100644 arho_feature_template/access_violation_plugin.txt create mode 100644 arho_feature_template/core/create_land_use_plan.py create mode 100644 arho_feature_template/core/update_plan.py create mode 100644 arho_feature_template/gui/new_land_use_plan_form.py create mode 100644 arho_feature_template/gui/new_land_use_plan_form.ui delete mode 100644 arho_feature_template/update_plan.py create mode 100644 arho_feature_template/utils/__init__.py 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/LandUsePlanDialog.py b/arho_feature_template/LandUsePlanDialog.py deleted file mode 100644 index e1ac784..0000000 --- a/arho_feature_template/LandUsePlanDialog.py +++ /dev/null @@ -1,52 +0,0 @@ -from qgis.PyQt.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QComboBox - -class LandUsePlanDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Define Land Use Plan") - - self.layout = QVBoxLayout() - - # ToDo! The id should be automatically set to next available id!! - self.id_label = QLabel("Plan ID:") - self.id_input = QLineEdit(self) - self.layout.addWidget(self.id_label) - self.layout.addWidget(self.id_input) - - # Input for custom name - self.name_label = QLabel("Plan Name:") - self.name_input = QLineEdit(self) - self.layout.addWidget(self.name_label) - self.layout.addWidget(self.name_input) - - # Plan type selection - self.type_label = QLabel("Plan Type:") - self.type_combo = QComboBox(self) - self.type_combo.addItems(["Asemakaava", "Maakuntakaava", "Yleiskaava"]) - self.layout.addWidget(self.type_label) - self.layout.addWidget(self.type_combo) - - self.submit_button = QPushButton("Submit", self) - self.submit_button.clicked.connect(self.on_submit) - self.layout.addWidget(self.submit_button) - - self.setLayout(self.layout) - - self.plan_id = None - self.plan_name = None - self.plan_type = None - - # Maybe the template should be read here and allow setting values in this dialog? - # So open additional fields in the dialog based on the type of plan selected. - - def on_submit(self): - self.plan_id = self.id_input.text() - self.plan_name = self.name_input.text() - # ToDo! plan_type should define which template is used. - self.plan_type = self.type_combo.currentText() - - if not self.plan_id or not self.plan_name: - QMessageBox.warning(self, "Input Error", "Both ID and Name fields must be filled out.") - return - - self.accept() \ No newline at end of file diff --git a/arho_feature_template/SimpleDrawTool.py b/arho_feature_template/SimpleDrawTool.py deleted file mode 100644 index e3bfddb..0000000 --- a/arho_feature_template/SimpleDrawTool.py +++ /dev/null @@ -1,16 +0,0 @@ -from qgis.gui import QgsMapToolDigitizeFeature, QgsMapToolCapture -from qgis.core import QgsProject -from qgis.utils import iface - -class SimpleDrawTool(QgsMapToolDigitizeFeature): - def __init__(self, canvas): - # Properly initializing with the required parameters - super().__init__(canvas, None, QgsMapToolCapture.CapturePolygon) - - self.canvas = canvas - self.layer = QgsProject.instance().mapLayersByName("Kaava")[0] - iface.setActiveLayer(self.layer) - - def addFeature(self, f): - self.layer.addFeature(f) - self.layer.triggerRepaint() \ No newline at end of file diff --git a/arho_feature_template/access_violation_plugin.txt b/arho_feature_template/access_violation_plugin.txt deleted file mode 100644 index 7c76836..0000000 --- a/arho_feature_template/access_violation_plugin.txt +++ /dev/null @@ -1,58 +0,0 @@ -from qgis.gui import QgsDockWidget, QgsMapToolDigitizeFeature, QgsAdvancedDigitizingDockWidget, QgsMapToolCapture - -def start_digitizing(self) -> None: - """Create a new QGIS project, add a layer group, create a new empty vector layer for digitizing, and add an OSM layer.""" - - # 1. Create a new project - project = QgsProject.instance() - project.clear() # Clear the current project (like starting a new one) - - # 2. Create a new group in the layer tree - group_name = "uusi kaava" - root = project.layerTreeRoot() - group = root.addGroup(group_name) - - # 3. Create a new empty vector layer (polygon layer for digitizing land use areas) - layer_name = "New Land Use Plan" - crs = "EPSG:4326" # Define the CRS (EPSG:4326 is WGS84, you can use any appropriate CRS) - new_layer = QgsVectorLayer(f"Polygon?crs={crs}", layer_name, "memory") - - if not new_layer.isValid(): - iface.messageBar().pushMessage("Error", "Failed to create new layer", level=3) - return - - # 4. Add the new layer to the project inside the "uusi kaava" group - QgsProject.instance().addMapLayer(new_layer, False) # Add layer to project but don't display it yet - group.insertLayer(0, new_layer) # Insert layer into the group at the first position - - # 5. Set the newly created layer as the active layer so digitizing can start - iface.setActiveLayer(new_layer) - - # 6. Start editing the layer (so the user can begin digitizing) - new_layer.startEditing() - - # 7. Add OpenStreetMap layer - osm_url = "type=xyz&url=https://tile.openstreetmap.org/{z}/{x}/{y}.png" - osm_layer = QgsRasterLayer(osm_url, "OpenStreetMap", "wms") - - if not osm_layer.isValid(): - iface.messageBar().pushMessage("Error", "Failed to add OpenStreetMap layer", level=3) - return - - # Add the OSM layer to the project - QgsProject.instance().addMapLayer(osm_layer) - - # 8. Create an instance of the advanced digitizing dock widget - advanced_digitizing_dock_widget = QgsAdvancedDigitizingDockWidget(iface.mapCanvas(), None) - - # 9. Create the digitizing tool - digitizing_tool = QgsMapToolDigitizeFeature(iface.mapCanvas(), advanced_digitizing_dock_widget, QgsMapToolCapture.CapturePolygon) - - # Ensure the layer is set correctly - digitizing_tool.setLayer(new_layer) # Set the layer to digitize - - # Set the map tool - iface.mapCanvas().setMapTool(digitizing_tool) - - # Notify the user that digitizing is ready - iface.messageBar().pushMessage("Info", f"New project created. Layer '{layer_name}' is ready for digitizing, and OpenStreetMap layer has been added.", level=0) \ No newline at end of file diff --git a/arho_feature_template/core/create_land_use_plan.py b/arho_feature_template/core/create_land_use_plan.py new file mode 100644 index 0000000..fbe838f --- /dev/null +++ b/arho_feature_template/core/create_land_use_plan.py @@ -0,0 +1,65 @@ +from qgis.core import QgsFeature, QgsGeometry, QgsVectorLayer +from qgis.PyQt.QtGui import QStandardItemModel +from qgis.utils import iface + +from .feature_template_library import TemplateGeometryDigitizeMapTool + + +class LandUsePlanTemplater: + def __init__(self): + self.active_template = None + self.library_configs = {} + self.template_model = QStandardItemModel() + + self.digitize_map_tool = TemplateGeometryDigitizeMapTool(iface.mapCanvas(), iface.cadDockWidget()) + self.digitize_map_tool.digitizingCompleted.connect(self.create_feature) + + def start_digitizing_for_layer(self, layer: QgsVectorLayer): + self.digitize_map_tool.clean() + self.digitize_map_tool.setLayer(layer) + iface.mapCanvas().setMapTool(self.digitize_map_tool) + + def create_feature(self, feature): + """Creates a new feature using stored dialog attributes.""" + print("create_feature called with feature:", feature) + + if not self.plan_id or not self.plan_name or not self.plan_type: + print("Plan information not available") + return + + layer = self.digitize_map_tool.layer() + + if not layer: + print("Layer is not set for digitizing.") + return + + print("Layer found:", layer.name(), "Editable:", layer.isEditable()) + new_feature = QgsFeature(layer.fields()) + + geometry = feature.geometry() + if not isinstance(geometry, QgsGeometry): + print("Expected geometry to be of type QgsGeometry, got:", type(geometry)) + return + + new_feature.setGeometry(geometry) + new_feature.setAttribute("id", self.plan_id) + new_feature.setAttribute("name", self.plan_name) + + if self.plan_type == "Detailed land use plan": + plan_type_value = 1 + elif self.plan_type == "General land use plan": + plan_type_value = 2 + elif self.plan_type == "Local land use plan": + plan_type_value = 3 + + new_feature.setAttribute("plan_type", plan_type_value) + + if not layer.isEditable(): + layer.startEditing() + + layer.beginEditCommand("Create land use plan feature.") + if layer.addFeature(new_feature): + print(f"Feature with ID {self.plan_id}, name {self.plan_name}, and type {plan_type_value} created successfully.") + else: + print("Failed to add feature to the layer.") + layer.commitChanges() diff --git a/arho_feature_template/core/update_plan.py b/arho_feature_template/core/update_plan.py new file mode 100644 index 0000000..2d0314c --- /dev/null +++ b/arho_feature_template/core/update_plan.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass +from typing import List + +from qgis.core import QgsMapLayer, QgsProject, QgsVectorLayer + + +# To be extended and moved +@dataclass +class LandUsePlan: + id: str + name: str + +# To be replaced later +LAYER_PLAN_ID_MAP = { + "land_use_plan": "id", + "land_use_area": "plan_id", + "Osa-alue": "plan_id", +} + +def update_selected_plan(new_plan: LandUsePlan): + """Update the project layers based on the selected land use plan.""" + id = new_plan.id + for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): + set_filter_for_vector_layer(layer_name, field_name, id) + +def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): + # Get layer and perform checks + layers = QgsProject.instance().mapLayersByName(layer_name) + if not _check_layer_count(layers): + return + layer = layers[0] + if not _check_vector_layer(layer): + return + + # Perform the filtering + query = f"{field_name} = {field_value}" + if not layer.setSubsetString(query): + # TODO: Convert to log msg? + print(f"ERROR: Failed to filter layer {layer_name} with query {query}") + +def _check_layer_count(layers: list) -> bool: + """Check if any layers are returned.""" + if not layers: + print("ERROR: No layers found with the specified name.") + 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): + print(f"ERROR: Layer {layer.name()} is not a vector layer: {type(layer)}") + return False + return True diff --git a/arho_feature_template/gui/new_land_use_plan_form.py b/arho_feature_template/gui/new_land_use_plan_form.py new file mode 100644 index 0000000..b8ac786 --- /dev/null +++ b/arho_feature_template/gui/new_land_use_plan_form.py @@ -0,0 +1,37 @@ +from collections import defaultdict +from importlib import resources + +from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QDialog + +ui_path = resources.files(__package__) / "new_land_use_plan_form.ui" +FormClass, _ = uic.loadUiType(ui_path) + + +class NewLandUsePlanForm(QDialog, FormClass): # type: ignore + """Dialog for creating a new land use plan with ID, Name, and Type.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + self.plan_widgets = defaultdict(dict) + + self.plan_widgets['id'] = self.lineEdit_plan_id + self.plan_widgets['name'] = self.lineEdit_plan_name + self.plan_widgets['type'] = self.comboBox_plan_type + + @property + def plan_id(self): + """Returns the entered Plan ID.""" + return self.lineEdit_plan_id.text() + + @property + def plan_name(self): + """Returns the entered Plan Name.""" + return self.lineEdit_plan_name.text() + + @property + def plan_type(self): + """Returns the selected Plan Type.""" + return self.comboBox_plan_type.currentText() diff --git a/arho_feature_template/gui/new_land_use_plan_form.ui b/arho_feature_template/gui/new_land_use_plan_form.ui new file mode 100644 index 0000000..f6f2ea9 --- /dev/null +++ b/arho_feature_template/gui/new_land_use_plan_form.ui @@ -0,0 +1,107 @@ + + + NewLandUsePlanForm + + + + 0 + 0 + 400 + 300 + + + + Create New Land Use Plan + + + + + + + + Plan ID: + + + + + + + + + + Plan Name: + + + + + + + + + + Plan Type: + + + + + + + + Detailed land use plan + + + + + General land use plan + + + + + Local land use plan + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + button_box + accepted() + NewLandUsePlanForm + accept() + + + button_box + rejected() + NewLandUsePlanForm + reject() + + + \ No newline at end of file diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 99d06f6..03dc42d 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -1,23 +1,22 @@ from __future__ import annotations import os - from typing import TYPE_CHECKING, Callable, cast from qgis.core import QgsProject -from qgis.PyQt.QtCore import QCoreApplication, QTranslator, Qt +from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction, QWidget, QDialog +from qgis.PyQt.QtWidgets import QAction, QWidget from qgis.utils import iface +from arho_feature_template.core.create_land_use_plan import LandUsePlanTemplater from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool +from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan +from arho_feature_template.gui.new_land_use_plan_form import NewLandUsePlanForm 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.misc_utils import PLUGIN_PATH -from arho_feature_template.update_plan import update_selected_plan, _check_vector_layer, LandUsePlan -from arho_feature_template.LandUsePlanDialog import LandUsePlanDialog if TYPE_CHECKING: from qgis.gui import QgisInterface, QgsMapTool @@ -129,10 +128,8 @@ def add_action( def initGui(self) -> None: # noqa N802 self.templater = FeatureTemplater() - new_path = os.path.join(PLUGIN_PATH, "resources/icons/city.png") # A placeholder icon - # Land use icons created by Fusion5085 - Flaticon - load_path = os.path.join(PLUGIN_PATH, "resources/icons/folder.png") - # Open icons created by Smashicons - Flaticon + 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) @@ -150,21 +147,20 @@ def initGui(self) -> None: # noqa N802 add_to_toolbar=True, ) - # Add additional action for "Lisää kaava" - new_action = self.add_action( - new_path, - text="Lisää kaava", - triggered_callback=self.start_digitizing, - parent=iface.mainWindow(), # Use iface instead of self.iface + self.new_land_use_plan_action = self.add_action( + plan_icon_path, + "Create New Land Use Plan", + self.show_land_use_plan_dialog, + add_to_menu=True, add_to_toolbar=True, + status_tip="Create a new land use plan", ) - # Add additional action for "Avaa kaava" - load_action = self.add_action( - load_path, - text="Avaa kaava", - triggered_callback=self.open_plan, - parent=iface.mainWindow(), # Use iface instead of self.iface + 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, ) @@ -172,51 +168,30 @@ def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> Non if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) - def start_digitizing(self) -> None: - dialog = LandUsePlanDialog() - if dialog.exec_() != QDialog.Accepted: - return - - # Create the LandUsePlan object from user input - new_plan = LandUsePlan(id=dialog.plan_id, name=dialog.plan_name) - plan_type = dialog.plan_type - - update_selected_plan(new_plan) + def show_land_use_plan_dialog(self): + dialog = NewLandUsePlanForm() + if dialog.exec_(): + new_land_use_plan = LandUsePlan(id=dialog.plan_id, name=dialog.plan_name) + update_selected_plan(new_land_use_plan) - layer_name = "Kaava" + self.land_use_plan_templater = LandUsePlanTemplater() + self.land_use_plan_templater.plan_id = dialog.plan_id + self.land_use_plan_templater.plan_name = dialog.plan_name + self.land_use_plan_templater.plan_type = dialog.plan_type - layers = QgsProject.instance().mapLayersByName(layer_name) - kaava_layer = layers[0] - - if not kaava_layer: - iface.messageBar().pushMessage("Error", f"Layer '{layer_name}' not found in the project", level=3) - return + # TODO: fix hard coded layer name. + layers = QgsProject.instance().mapLayersByName("land_use_plan") + if not layers: + iface.messageBar().pushMessage("Error", "Layer 'land_use_plan' not found in the project", level=3) + return - # new_layer = layers[0] + land_use_plan_layer = layers[0] - if not _check_vector_layer(kaava_layer): - iface.messageBar().pushMessage("Error", f"Layer '{layer_name}' is not a valid vector layer", level=3) - return - - iface.setActiveLayer(kaava_layer) - if not kaava_layer.isEditable(): - kaava_layer.startEditing() - - # digitizing_tool = SimpleDrawTool(iface.mapCanvas()) - - # Set the custom draw tool as the active tool on the canvas - # iface.mapCanvas().setMapTool(digitizing_tool) - - # Notify the user - iface.messageBar().pushMessage( - "Info", - f"Layer '{layer_name}' is filtered by plan '{new_plan.name}' of type '{plan_type}' and ready for digitizing.", - level=0, - ) + if land_use_plan_layer: + self.land_use_plan_templater.start_digitizing_for_layer(land_use_plan_layer) - def open_plan(self) -> None: + def load_existing_land_use_plan(self) -> None: """Open existing land use plan.""" - pass def unload(self) -> None: """Removes the plugin menu item and icon from QGIS GUI.""" @@ -231,4 +206,4 @@ def dock_visibility_changed(self, visible: bool) -> None: # noqa: FBT001 self.template_dock_action.setChecked(visible) def toggle_template_dock(self, show: bool) -> None: # noqa: FBT001 - self.templater.template_dock.setUserVisible(show) \ No newline at end of file + self.templater.template_dock.setUserVisible(show) diff --git a/arho_feature_template/update_plan.py b/arho_feature_template/update_plan.py deleted file mode 100644 index 7311453..0000000 --- a/arho_feature_template/update_plan.py +++ /dev/null @@ -1,105 +0,0 @@ -from dataclasses import dataclass - -from typing import List - -from qgis.core import QgsProject, QgsVectorLayer, QgsMapLayer - - -# To be extended and moved -@dataclass -class LandUsePlan: - id: str - name: str - - -# To be replaced later -LAYER_PLAN_ID_MAP = { - "Kaava": "id", - "land_use_area": "plan_id", - "Osa-alue": "plan_id", -} - -def update_selected_plan(new_plan: LandUsePlan): - """Update the project layers based on the selected land use plan.""" - id = new_plan.id - for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): - set_filter_for_vector_layer(layer_name, field_name, id) - - -def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): - """Get the layer by name, check validity, and apply a filter based on the field value.""" - # Get the layer(s) by name - layers = QgsProject.instance().mapLayersByName(layer_name) - - # Ensure at least one layer was found - if not _check_layer_count(layers): - return - - # Access the first layer from the list - layer = layers[0] - - # Check if the layer is a valid vector layer - if not _check_vector_layer(layer): - return - - # Apply filtering logic here since the layer is valid - print(f"Layer {layer.name()} is valid for filtering.") - # Further operations like setting filter or using this layer for processing - -def _check_layer_count(layers: list) -> bool: - """Check if any layers are returned.""" - if not layers: - print(f"ERROR: No layers found with the specified name.") - 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): - print(f"ERROR: Layer {layer.name()} is not a vector layer: {type(layer)}") - return False - return True - -# def update_selected_plan(new_plan: LandUsePlan): - # id = new_plan.id - # for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): - # set_filter_for_vector_layer(layer_name, field_name, id) - - -# def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str): - # Get layer and perform checks - # layers = QgsProject.instance().mapLayersByName(layer_name) - # if not _check_layer_count(layers): - # return - # layer = layers[0] - # if not _check_vector_layer(layer): - # return - - # Check if the layer is empty - # if layer.featureCount() == 0: - # print(f"INFO: Layer '{layer_name}' is empty. No features to filter.") - # return # Simply return without applying any filter - - # else: - # Perform the filtering - # query = f"{field_name} = {field_value}" - # if not layer.setSubsetString(query): - # TODO: Convert to log msg? - # print(f"ERROR: Failed to filter layer {layer_name} with query {query}") - -# def _check_layer_count(layers: List[QgsMapLayer]) -> bool: - # if len(layers) > 1: - # TODO: Convert to log msg? - # print(f"ERROR: Found multiple layers ({len(layers)}) with same name ({layers[0].name()}).") - # return False - # return True - - -# def _check_vector_layer(layer: QgsMapLayer) -> bool: - # if not isinstance(layer, QgsVectorLayer): - # TODO: Convert to log msg? - # print(f"ERROR: Layer {layer.name()} is not a vector layer: f{type(layer)}") - # print(f"Error this is not a vector layer! {layer}") - # return False - # return True \ No newline at end of file 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/misc_utils.py b/arho_feature_template/utils/misc_utils.py index 3e5a741..42f38d6 100644 --- a/arho_feature_template/utils/misc_utils.py +++ b/arho_feature_template/utils/misc_utils.py @@ -1,3 +1,3 @@ import os -PLUGIN_PATH = os.path.dirname(os.path.dirname(__file__)) \ No newline at end of file +PLUGIN_PATH = os.path.dirname(os.path.dirname(__file__)) From 6d49d4a6d1697673cc114b255cd47ffa7882b94d Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Wed, 9 Oct 2024 12:36:14 +0300 Subject: [PATCH 03/12] Ruff-format fixes --- .../core/create_land_use_plan.py | 12 ++---------- arho_feature_template/core/update_plan.py | 17 +++++++++++------ .../gui/new_land_use_plan_form.py | 6 +++--- .../gui/new_land_use_plan_form.ui | 2 +- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/arho_feature_template/core/create_land_use_plan.py b/arho_feature_template/core/create_land_use_plan.py index fbe838f..982d3cd 100644 --- a/arho_feature_template/core/create_land_use_plan.py +++ b/arho_feature_template/core/create_land_use_plan.py @@ -2,7 +2,7 @@ from qgis.PyQt.QtGui import QStandardItemModel from qgis.utils import iface -from .feature_template_library import TemplateGeometryDigitizeMapTool +from arho_feature_template.core.feature_template_library import TemplateGeometryDigitizeMapTool class LandUsePlanTemplater: @@ -21,24 +21,19 @@ def start_digitizing_for_layer(self, layer: QgsVectorLayer): def create_feature(self, feature): """Creates a new feature using stored dialog attributes.""" - print("create_feature called with feature:", feature) if not self.plan_id or not self.plan_name or not self.plan_type: - print("Plan information not available") return layer = self.digitize_map_tool.layer() if not layer: - print("Layer is not set for digitizing.") return - print("Layer found:", layer.name(), "Editable:", layer.isEditable()) new_feature = QgsFeature(layer.fields()) geometry = feature.geometry() if not isinstance(geometry, QgsGeometry): - print("Expected geometry to be of type QgsGeometry, got:", type(geometry)) return new_feature.setGeometry(geometry) @@ -58,8 +53,5 @@ def create_feature(self, feature): layer.startEditing() layer.beginEditCommand("Create land use plan feature.") - if layer.addFeature(new_feature): - print(f"Feature with ID {self.plan_id}, name {self.plan_name}, and type {plan_type_value} created successfully.") - else: - print("Failed to add feature to the layer.") + layer.addFeature(new_feature) layer.commitChanges() diff --git a/arho_feature_template/core/update_plan.py b/arho_feature_template/core/update_plan.py index 2d0314c..93342bf 100644 --- a/arho_feature_template/core/update_plan.py +++ b/arho_feature_template/core/update_plan.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from typing import List from qgis.core import QgsMapLayer, QgsProject, QgsVectorLayer +from qgis.utils import iface # To be extended and moved @@ -10,6 +10,7 @@ class LandUsePlan: id: str name: str + # To be replaced later LAYER_PLAN_ID_MAP = { "land_use_plan": "id", @@ -17,11 +18,13 @@ class LandUsePlan: "Osa-alue": "plan_id", } + def update_selected_plan(new_plan: LandUsePlan): """Update the project layers based on the selected land use plan.""" - id = new_plan.id + plan_id = new_plan.id for layer_name, field_name in LAYER_PLAN_ID_MAP.items(): - set_filter_for_vector_layer(layer_name, field_name, 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): # Get layer and perform checks @@ -36,18 +39,20 @@ def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: s query = f"{field_name} = {field_value}" if not layer.setSubsetString(query): # TODO: Convert to log msg? - print(f"ERROR: Failed to filter layer {layer_name} with query {query}") + iface.messageBar().pushMessage("Error", f"Failed to filter layer {layer_name} with query {query}", level=3) + def _check_layer_count(layers: list) -> bool: """Check if any layers are returned.""" if not layers: - print("ERROR: No layers found with the specified name.") + 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): - print(f"ERROR: Layer {layer.name()} is not a vector layer: {type(layer)}") + 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/new_land_use_plan_form.py b/arho_feature_template/gui/new_land_use_plan_form.py index b8ac786..7c416c7 100644 --- a/arho_feature_template/gui/new_land_use_plan_form.py +++ b/arho_feature_template/gui/new_land_use_plan_form.py @@ -17,9 +17,9 @@ def __init__(self, parent=None): self.plan_widgets = defaultdict(dict) - self.plan_widgets['id'] = self.lineEdit_plan_id - self.plan_widgets['name'] = self.lineEdit_plan_name - self.plan_widgets['type'] = self.comboBox_plan_type + self.plan_widgets["id"] = self.lineEdit_plan_id + self.plan_widgets["name"] = self.lineEdit_plan_name + self.plan_widgets["type"] = self.comboBox_plan_type @property def plan_id(self): diff --git a/arho_feature_template/gui/new_land_use_plan_form.ui b/arho_feature_template/gui/new_land_use_plan_form.ui index f6f2ea9..e94ac1f 100644 --- a/arho_feature_template/gui/new_land_use_plan_form.ui +++ b/arho_feature_template/gui/new_land_use_plan_form.ui @@ -104,4 +104,4 @@ reject() - \ No newline at end of file + From 3b7fc6956aebe23eb138498829175f7b2a316ac1 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Wed, 9 Oct 2024 12:54:32 +0300 Subject: [PATCH 04/12] Enable layer editing before digitizing geometry. --- arho_feature_template/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 03dc42d..2752cd7 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -188,6 +188,8 @@ def show_land_use_plan_dialog(self): land_use_plan_layer = layers[0] if land_use_plan_layer: + if not land_use_plan_layer.isEditable(): + land_use_plan_layer.startEditing() self.land_use_plan_templater.start_digitizing_for_layer(land_use_plan_layer) def load_existing_land_use_plan(self) -> None: From 3b0d0e3be72c2804fe60ea9bcf70f7ebef715718 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Fri, 18 Oct 2024 00:03:22 +0300 Subject: [PATCH 05/12] Removed custom form for creating AddFeature instead. new plan, use Use default add feature to create new plan. Form used to define feature comes from QGIS-project. --- .../gui/new_land_use_plan_form.py | 37 ------ .../gui/new_land_use_plan_form.ui | 107 ------------------ arho_feature_template/plugin.py | 67 ++++++----- 3 files changed, 37 insertions(+), 174 deletions(-) delete mode 100644 arho_feature_template/gui/new_land_use_plan_form.py delete mode 100644 arho_feature_template/gui/new_land_use_plan_form.ui diff --git a/arho_feature_template/gui/new_land_use_plan_form.py b/arho_feature_template/gui/new_land_use_plan_form.py deleted file mode 100644 index 7c416c7..0000000 --- a/arho_feature_template/gui/new_land_use_plan_form.py +++ /dev/null @@ -1,37 +0,0 @@ -from collections import defaultdict -from importlib import resources - -from qgis.PyQt import uic -from qgis.PyQt.QtWidgets import QDialog - -ui_path = resources.files(__package__) / "new_land_use_plan_form.ui" -FormClass, _ = uic.loadUiType(ui_path) - - -class NewLandUsePlanForm(QDialog, FormClass): # type: ignore - """Dialog for creating a new land use plan with ID, Name, and Type.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setupUi(self) - - self.plan_widgets = defaultdict(dict) - - self.plan_widgets["id"] = self.lineEdit_plan_id - self.plan_widgets["name"] = self.lineEdit_plan_name - self.plan_widgets["type"] = self.comboBox_plan_type - - @property - def plan_id(self): - """Returns the entered Plan ID.""" - return self.lineEdit_plan_id.text() - - @property - def plan_name(self): - """Returns the entered Plan Name.""" - return self.lineEdit_plan_name.text() - - @property - def plan_type(self): - """Returns the selected Plan Type.""" - return self.comboBox_plan_type.currentText() diff --git a/arho_feature_template/gui/new_land_use_plan_form.ui b/arho_feature_template/gui/new_land_use_plan_form.ui deleted file mode 100644 index e94ac1f..0000000 --- a/arho_feature_template/gui/new_land_use_plan_form.ui +++ /dev/null @@ -1,107 +0,0 @@ - - - NewLandUsePlanForm - - - - 0 - 0 - 400 - 300 - - - - Create New Land Use Plan - - - - - - - - Plan ID: - - - - - - - - - - Plan Name: - - - - - - - - - - Plan Type: - - - - - - - - Detailed land use plan - - - - - General land use plan - - - - - Local land use plan - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - button_box - accepted() - NewLandUsePlanForm - accept() - - - button_box - rejected() - NewLandUsePlanForm - reject() - - - diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 2752cd7..590a766 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -3,16 +3,15 @@ import os from typing import TYPE_CHECKING, Callable, cast -from qgis.core import QgsProject +from qgis.core import QgsProject, QgsWkbTypes from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QAction, QWidget from qgis.utils import iface -from arho_feature_template.core.create_land_use_plan import LandUsePlanTemplater from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool -from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan -from arho_feature_template.gui.new_land_use_plan_form import NewLandUsePlanForm + +# from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan 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 @@ -149,11 +148,11 @@ def initGui(self) -> None: # noqa N802 self.new_land_use_plan_action = self.add_action( plan_icon_path, - "Create New Land Use Plan", - self.show_land_use_plan_dialog, + "Create New Plan", + self.digitize_new_plan, add_to_menu=True, add_to_toolbar=True, - status_tip="Create a new land use plan", + status_tip="Create a new plan", ) self.load_land_use_plan_action = self.add_action( @@ -168,29 +167,37 @@ def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> Non if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) - def show_land_use_plan_dialog(self): - dialog = NewLandUsePlanForm() - if dialog.exec_(): - new_land_use_plan = LandUsePlan(id=dialog.plan_id, name=dialog.plan_name) - update_selected_plan(new_land_use_plan) - - self.land_use_plan_templater = LandUsePlanTemplater() - self.land_use_plan_templater.plan_id = dialog.plan_id - self.land_use_plan_templater.plan_name = dialog.plan_name - self.land_use_plan_templater.plan_type = dialog.plan_type - - # TODO: fix hard coded layer name. - layers = QgsProject.instance().mapLayersByName("land_use_plan") - if not layers: - iface.messageBar().pushMessage("Error", "Layer 'land_use_plan' not found in the project", level=3) - return - - land_use_plan_layer = layers[0] - - if land_use_plan_layer: - if not land_use_plan_layer.isEditable(): - land_use_plan_layer.startEditing() - self.land_use_plan_templater.start_digitizing_for_layer(land_use_plan_layer) + def digitize_new_plan(self): + # Activate and start editing the Kaava-layer + 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) + + if kaava_layer.geometryType() != QgsWkbTypes.PolygonGeometry: + iface.messageBar().pushMessage("Error", "Layer 'Kaava' is not a polygon layer", level=3) + return + + kaava_layer.featureAdded.connect(self.commit_new_plan) + + iface.actionAddFeature().trigger() + + def commit_new_plan(self): + kaava_layer = iface.activeLayer() + + kaava_layer.featureAdded.disconnect() + + if kaava_layer.commitChanges(): + iface.messageBar().pushMessage("Info", "Feature committed successfully", level=0) + else: + iface.messageBar().pushMessage("Error", "Failed to commit feature", level=3) def load_existing_land_use_plan(self) -> None: """Open existing land use plan.""" From a6dbec1e4dd10c1b693447505a99b8d98a4663f4 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Mon, 21 Oct 2024 02:52:24 +0300 Subject: [PATCH 06/12] FIlter layers after new plan is committed. After committing new plan feature, filter layers based on the id of the new plan. The id is retrieved by comparing ids before and after commit. --- .../core/create_land_use_plan.py | 57 ------------------- arho_feature_template/core/update_plan.py | 27 +++++---- arho_feature_template/plugin.py | 53 +++++++++++------ 3 files changed, 53 insertions(+), 84 deletions(-) delete mode 100644 arho_feature_template/core/create_land_use_plan.py diff --git a/arho_feature_template/core/create_land_use_plan.py b/arho_feature_template/core/create_land_use_plan.py deleted file mode 100644 index 982d3cd..0000000 --- a/arho_feature_template/core/create_land_use_plan.py +++ /dev/null @@ -1,57 +0,0 @@ -from qgis.core import QgsFeature, QgsGeometry, QgsVectorLayer -from qgis.PyQt.QtGui import QStandardItemModel -from qgis.utils import iface - -from arho_feature_template.core.feature_template_library import TemplateGeometryDigitizeMapTool - - -class LandUsePlanTemplater: - def __init__(self): - self.active_template = None - self.library_configs = {} - self.template_model = QStandardItemModel() - - self.digitize_map_tool = TemplateGeometryDigitizeMapTool(iface.mapCanvas(), iface.cadDockWidget()) - self.digitize_map_tool.digitizingCompleted.connect(self.create_feature) - - def start_digitizing_for_layer(self, layer: QgsVectorLayer): - self.digitize_map_tool.clean() - self.digitize_map_tool.setLayer(layer) - iface.mapCanvas().setMapTool(self.digitize_map_tool) - - def create_feature(self, feature): - """Creates a new feature using stored dialog attributes.""" - - if not self.plan_id or not self.plan_name or not self.plan_type: - return - - layer = self.digitize_map_tool.layer() - - if not layer: - return - - new_feature = QgsFeature(layer.fields()) - - geometry = feature.geometry() - if not isinstance(geometry, QgsGeometry): - return - - new_feature.setGeometry(geometry) - new_feature.setAttribute("id", self.plan_id) - new_feature.setAttribute("name", self.plan_name) - - if self.plan_type == "Detailed land use plan": - plan_type_value = 1 - elif self.plan_type == "General land use plan": - plan_type_value = 2 - elif self.plan_type == "Local land use plan": - plan_type_value = 3 - - new_feature.setAttribute("plan_type", plan_type_value) - - if not layer.isEditable(): - layer.startEditing() - - layer.beginEditCommand("Create land use plan feature.") - layer.addFeature(new_feature) - layer.commitChanges() diff --git a/arho_feature_template/core/update_plan.py b/arho_feature_template/core/update_plan.py index 93342bf..640a5ef 100644 --- a/arho_feature_template/core/update_plan.py +++ b/arho_feature_template/core/update_plan.py @@ -8,13 +8,15 @@ @dataclass class LandUsePlan: id: str - name: str # To be replaced later LAYER_PLAN_ID_MAP = { - "land_use_plan": "id", - "land_use_area": "plan_id", + "Kaava": "id", + "Maankäytön kohteet": "plan_id", + "Muut pisteet": "plan_id", + "Viivat": "plan_id", + "Aluevaraus": "plan_id", "Osa-alue": "plan_id", } @@ -22,24 +24,27 @@ class LandUsePlan: 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): - # Get layer and perform checks + """Set a filter for the given vector layer.""" layers = QgsProject.instance().mapLayersByName(layer_name) + if not _check_layer_count(layers): return + layer = layers[0] - if not _check_vector_layer(layer): - return - # Perform the filtering - query = f"{field_name} = {field_value}" - if not layer.setSubsetString(query): - # TODO: Convert to log msg? - iface.messageBar().pushMessage("Error", f"Failed to filter layer {layer_name} with query {query}", level=3) + # Create the filter expression directly based on the plan_id + expression = f"\"{field_name}\" = '{field_value}'" # Properly formatted filter expression + + # 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: diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 590a766..124a5f9 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -3,15 +3,14 @@ import os from typing import TYPE_CHECKING, Callable, cast -from qgis.core import QgsProject, QgsWkbTypes +from qgis.core import QgsProject, QgsVectorLayer from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QAction, QWidget from qgis.utils import iface from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool - -# from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan +from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan 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 @@ -135,7 +134,6 @@ def initGui(self) -> None: # noqa N802 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", @@ -167,8 +165,18 @@ def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> Non if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) + 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("") + def digitize_new_plan(self): - # Activate and start editing the Kaava-layer + # Filtered layers are not editable, so clear filters first. + self.clear_all_filters() + # Find and set the "Kaava" layer layers = QgsProject.instance().mapLayersByName("Kaava") if not layers: iface.messageBar().pushMessage("Error", "Layer 'Kaava' not found", level=3) @@ -181,23 +189,36 @@ def digitize_new_plan(self): iface.setActiveLayer(kaava_layer) - if kaava_layer.geometryType() != QgsWkbTypes.PolygonGeometry: - iface.messageBar().pushMessage("Error", "Layer 'Kaava' is not a polygon layer", level=3) - return - - kaava_layer.featureAdded.connect(self.commit_new_plan) - iface.actionAddFeature().trigger() + kaava_layer.featureAdded.connect(self.feature_added) - def commit_new_plan(self): + def feature_added(self): kaava_layer = iface.activeLayer() + 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() + + # Finds the feature id that was committed by comparing ids before commit to features 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) - kaava_layer.featureAdded.disconnect() + if new_feature.isValid(): + feature_id_value = new_feature["id"] # UUID of the new feature + iface.messageBar().pushMessage("Info", f"Feature 'id' field value: {feature_id_value}", level=0) - if kaava_layer.commitChanges(): - iface.messageBar().pushMessage("Info", "Feature committed successfully", level=0) + # plan = LandUsePlan(feature_id_value) + update_selected_plan(LandUsePlan(feature_id_value)) + else: + iface.messageBar().pushMessage("Error", "Invalid feature retrieved.", level=3) else: - iface.messageBar().pushMessage("Error", "Failed to commit feature", level=3) + iface.messageBar().pushMessage("Error", "No new feature was added.", level=3) def load_existing_land_use_plan(self) -> None: """Open existing land use plan.""" From dff72e8b1a3201d7d1b6e73831f0ba360f3692cf Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Tue, 22 Oct 2024 13:15:52 +0300 Subject: [PATCH 07/12] Disconnect featureAdded signal. Move code from plugin to new_plan. Disconnect featureAdded signal. Fixes commit errors. Moved code from plugin.py to new_plan.py --- arho_feature_template/core/new_plan.py | 64 +++++++++++++++++++ arho_feature_template/core/update_plan.py | 3 +- arho_feature_template/plugin.py | 58 +---------------- .../template_libraries/asemakaava-sample.yaml | 2 + 4 files changed, 70 insertions(+), 57 deletions(-) create mode 100644 arho_feature_template/core/new_plan.py diff --git a/arho_feature_template/core/new_plan.py b/arho_feature_template/core/new_plan.py new file mode 100644 index 0000000..df996b1 --- /dev/null +++ b/arho_feature_template/core/new_plan.py @@ -0,0 +1,64 @@ +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 index 640a5ef..d083090 100644 --- a/arho_feature_template/core/update_plan.py +++ b/arho_feature_template/core/update_plan.py @@ -39,8 +39,7 @@ def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: s layer = layers[0] - # Create the filter expression directly based on the plan_id - expression = f"\"{field_name}\" = '{field_value}'" # Properly formatted filter expression + expression = f"\"{field_name}\" = '{field_value}'" # Apply the filter to the layer if not layer.setSubsetString(expression): diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 124a5f9..e60d754 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -3,14 +3,13 @@ import os from typing import TYPE_CHECKING, Callable, cast -from qgis.core import QgsProject, QgsVectorLayer from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QAction, QWidget from qgis.utils import iface from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool -from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan +from arho_feature_template.core.new_plan import NewPlan 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 @@ -125,6 +124,7 @@ 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 @@ -165,60 +165,8 @@ def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> Non if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) - 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("") - def digitize_new_plan(self): - # Filtered layers are not editable, so clear filters first. - self.clear_all_filters() - # Find and set the "Kaava" layer - 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() - kaava_layer.featureAdded.connect(self.feature_added) - - def feature_added(self): - kaava_layer = iface.activeLayer() - 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() - - # Finds the feature id that was committed by comparing ids before commit to features 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"] # UUID of the new feature - iface.messageBar().pushMessage("Info", f"Feature 'id' field value: {feature_id_value}", level=0) - - # plan = LandUsePlan(feature_id_value) - 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) + self.new_plan.add_new_plan() def load_existing_land_use_plan(self) -> None: """Open existing land use plan.""" 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 From cdc568ce835d5e6528649bf00770c3eb78f70cec Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Tue, 22 Oct 2024 13:22:41 +0300 Subject: [PATCH 08/12] Ruff format fix --- arho_feature_template/core/new_plan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/arho_feature_template/core/new_plan.py b/arho_feature_template/core/new_plan.py index df996b1..97dd5ee 100644 --- a/arho_feature_template/core/new_plan.py +++ b/arho_feature_template/core/new_plan.py @@ -26,7 +26,6 @@ def add_new_plan(self): # Connect the featureAdded signal kaava_layer.featureAdded.connect(self.feature_added) - def feature_added(self): kaava_layer = iface.activeLayer() kaava_layer.featureAdded.disconnect() From d7cd9fc9efe8a3cad0f86b01a5385733fa48c0f5 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Tue, 29 Oct 2024 09:49:09 +0200 Subject: [PATCH 09/12] feat: implement test version of add plan regulation group button in template attribute form --- .../gui/plan_regulation_group_widget.py | 2 +- .../gui/template_attribute_form.py | 46 +++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/arho_feature_template/gui/plan_regulation_group_widget.py b/arho_feature_template/gui/plan_regulation_group_widget.py index 96e5784..3131dcf 100644 --- a/arho_feature_template/gui/plan_regulation_group_widget.py +++ b/arho_feature_template/gui/plan_regulation_group_widget.py @@ -71,7 +71,7 @@ def create_widgets_for_plan_regulation(self, plan_regulation_feature: Feature): 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..1c4ec3b 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,51 @@ 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) + self.remove_spacer() + self.plan_regulation_group_scrollarea_contents.layout().addWidget(new_plan_regulation_group) + self.add_spacer() 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}") From 5b5dfa432410a952d5822a4c0661a748dd1b3b78 Mon Sep 17 00:00:00 2001 From: Miikka Kallio Date: Fri, 25 Oct 2024 12:32:14 +0300 Subject: [PATCH 10/12] Added functionality to load existing plan. Added dialog for loading plan. Added database connection selection, searchable listing of all plans in the database. Filtering of layers based on id of selected plan. --- arho_feature_template/core/exceptions.py | 5 + arho_feature_template/gui/ask_credentials.py | 36 ++++ arho_feature_template/gui/ask_credentials.ui | 66 +++++++ arho_feature_template/gui/load_plan_dialog.py | 178 ++++++++++++++++++ arho_feature_template/gui/load_plan_dialog.ui | 126 +++++++++++++ arho_feature_template/plugin.py | 32 +++- arho_feature_template/utils/db_utils.py | 116 ++++++++++++ 7 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 arho_feature_template/core/exceptions.py create mode 100644 arho_feature_template/gui/ask_credentials.py create mode 100644 arho_feature_template/gui/ask_credentials.ui create mode 100644 arho_feature_template/gui/load_plan_dialog.py create mode 100644 arho_feature_template/gui/load_plan_dialog.ui create mode 100644 arho_feature_template/utils/db_utils.py diff --git a/arho_feature_template/core/exceptions.py b/arho_feature_template/core/exceptions.py new file mode 100644 index 0000000..7b77bfb --- /dev/null +++ b/arho_feature_template/core/exceptions.py @@ -0,0 +1,5 @@ +from arho_feature_template.qgis_plugin_tools.tools.exceptions import QgsPluginException + + +class AuthConfigException(QgsPluginException): + pass diff --git a/arho_feature_template/gui/ask_credentials.py b/arho_feature_template/gui/ask_credentials.py new file mode 100644 index 0000000..5233949 --- /dev/null +++ b/arho_feature_template/gui/ask_credentials.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from importlib import resources + +from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QLineEdit + +# Load the .ui file path using importlib resources +ui_path = resources.files(__package__) / "ask_credentials.ui" + +# Use uic.loadUiType to load the UI definition from the .ui file +DbAskCredentialsDialogBase, _ = uic.loadUiType(ui_path) + + +class DbAskCredentialsDialog(QDialog, DbAskCredentialsDialogBase): # type: ignore + def __init__(self, parent: QDialog = None): + super().__init__(parent) + + # Set up the UI from the loaded .ui file + self.setupUi(self) + + # The UI elements defined in the .ui file + self.userLineEdit: QLineEdit = self.findChild(QLineEdit, "userLineEdit") + self.pwdLineEdit: QLineEdit = self.findChild(QLineEdit, "pwdLineEdit") + self.buttonBox: QDialogButtonBox = self.findChild(QDialogButtonBox, "buttonBox") + + # Connect the OK and Cancel buttons to their respective functions + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + def get_credentials(self) -> tuple[str, str]: + """ + Returns the entered username and password. + :return: Tuple (username, password) + """ + return self.userLineEdit.text(), self.pwdLineEdit.text() diff --git a/arho_feature_template/gui/ask_credentials.ui b/arho_feature_template/gui/ask_credentials.ui new file mode 100644 index 0000000..cf42490 --- /dev/null +++ b/arho_feature_template/gui/ask_credentials.ui @@ -0,0 +1,66 @@ + + + DbAskCredentialsDialogBase + + + + 0 + 0 + 400 + 200 + + + + Käyttäjän autentikaatio + + + + + + + + Käyttäjä: + + + + + + + Käyttäjä... + + + + + + + Salasana: + + + + + + + Salasana... + + + QLineEdit::Password + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + 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..250d74b --- /dev/null +++ b/arho_feature_template/gui/load_plan_dialog.py @@ -0,0 +1,178 @@ +from importlib import resources + +import psycopg2 +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, QLineEdit, QMessageBox, QPushButton, QTableView + +from arho_feature_template.gui.ask_credentials import DbAskCredentialsDialog +from arho_feature_template.utils.db_utils import get_db_connection_params + +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 + def __init__(self, parent, connections): + super().__init__(parent) + + uic.loadUi(ui_path, self) + + self.connectionComboBox: QComboBox = self.findChild(QComboBox, "connectionComboBox") + self.planTableView: QTableView = self.findChild(QTableView, "planTableView") + self.okButton: QPushButton = self.findChild(QPushButton, "okButton") + + self.searchLineEdit: QLineEdit = self.findChild(QLineEdit, "searchLineEdit") + self.searchLineEdit.setPlaceholderText("Etsi kaavoja...") + + self.connectionComboBox.addItems(connections) + + self.connectionComboBox.currentIndexChanged.connect(self.load_plans) + self.okButton.clicked.connect(self.accept) + + self.okButton.setEnabled(False) + + self.filterProxyModel = PlanFilterProxyModel(self) + self.filterProxyModel.setFilterCaseSensitivity(Qt.CaseInsensitive) + + self.planTableView.setModel(self.filterProxyModel) + self.searchLineEdit.textChanged.connect(self.filter_plans) + + self.selected_plan_id = None + + def load_plans(self): + selected_connection = self.connectionComboBox.currentText() + if not selected_connection: + self.planTableView.setModel(QStandardItemModel()) + return + + cursor = None + conn = None + + try: + conn_params = get_db_connection_params(selected_connection) + + if not conn_params.get("user") or not conn_params.get("password"): + # Trigger dialog to ask for missing credentials + dialog = DbAskCredentialsDialog(self) + dialog.rejected.connect(self.reject) + if dialog.exec() == QDialog.Accepted: + user, password = dialog.get_credentials() + conn_params["user"] = user + conn_params["password"] = password + + conn = psycopg2.connect( + host=conn_params["host"], + port=conn_params["port"], + dbname=conn_params["dbname"], + user=conn_params["user"], + password=conn_params["password"], + sslmode=conn_params["sslmode"], + ) + + cursor = conn.cursor() + + cursor.execute(""" + 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; + """) + plans = cursor.fetchall() + + model = QStandardItemModel(len(plans), 5) + model.setHorizontalHeaderLabels( + [ + "ID", + "Tuottajan kaavatunnus", + "Nimi", + "Kaavan elinkaaren tila", + "Kaavalaji", + ] + ) + + for row_idx, plan in enumerate(plans): + model.setItem(row_idx, 0, QStandardItem(str(plan[0]))) # id + model.setItem(row_idx, 1, QStandardItem(str(plan[1]))) # producer_plan_identifier + model.setItem(row_idx, 2, QStandardItem(str(plan[2]))) # name_fin + model.setItem(row_idx, 3, QStandardItem(str(plan[3]))) # lifecycle_status_fin + model.setItem(row_idx, 4, QStandardItem(str(plan[4]))) # plan_type_fin + + self.filterProxyModel.setSourceModel(model) + + self.planTableView.setSelectionMode(QTableView.SingleSelection) + self.planTableView.setSelectionBehavior(QTableView.SelectRows) + + self.planTableView.selectionModel().selectionChanged.connect(self.on_selection_changed) + + except ValueError as ve: + QMessageBox.critical(self, "Connection Error", str(ve)) + self.planTableView.setModel(QStandardItemModel()) + + except Exception as e: # noqa: BLE001 + QMessageBox.critical(self, "Error", f"Failed to load plans: {e}") + self.planTableView.setModel(QStandardItemModel()) + + finally: + if cursor: + cursor.close() + if conn: + conn.close() + + 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() + if selection: + selected_row = selection[0].row() + self.selected_plan_id = self.planTableView.model().index(selected_row, 0).data() + self.okButton.setEnabled(True) + else: + self.selected_plan_id = None + self.okButton.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..d0e1a8f --- /dev/null +++ b/arho_feature_template/gui/load_plan_dialog.ui @@ -0,0 +1,126 @@ + + + LoadPlanDialog + + + Avaa kaava + + + + 800 + 600 + + + + + + + + + Valitse tietokantayhteys: + + + + + + + + 0 + 0 + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + + + + + Etsi kaavoja: + + + + + + + Etsi kaavoja... + + + + 0 + 0 + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + + + + + + Kaavat: + + + + + + + + 1 + 1 + + + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + + + + + + OK + + + + 0 + 0 + + + + + + + + + diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index e60d754..295290b 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -5,14 +5,17 @@ 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_connections from arho_feature_template.utils.misc_utils import PLUGIN_PATH if TYPE_CHECKING: @@ -134,6 +137,7 @@ def initGui(self) -> None: # noqa N802 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", @@ -146,11 +150,11 @@ def initGui(self) -> None: # noqa N802 self.new_land_use_plan_action = self.add_action( plan_icon_path, - "Create New Plan", - self.digitize_new_plan, + "Create New Land Use Plan", + self.add_new_plan, add_to_menu=True, add_to_toolbar=True, - status_tip="Create a new plan", + status_tip="Create a new land use plan", ) self.load_land_use_plan_action = self.add_action( @@ -165,11 +169,29 @@ def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> Non if not isinstance(new_tool, TemplateGeometryDigitizeMapTool): self.template_dock_action.setChecked(False) - def digitize_new_plan(self): + 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_connections() + + 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.""" diff --git a/arho_feature_template/utils/db_utils.py b/arho_feature_template/utils/db_utils.py new file mode 100644 index 0000000..e28d081 --- /dev/null +++ b/arho_feature_template/utils/db_utils.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import logging + +from qgis.core import QgsApplication, QgsAuthMethodConfig +from qgis.PyQt.QtCore import QSettings +from qgis.PyQt.QtWidgets import QDialog, QMessageBox + +from arho_feature_template.core.exceptions import AuthConfigException +from arho_feature_template.gui.ask_credentials import DbAskCredentialsDialog +from arho_feature_template.qgis_plugin_tools.tools.custom_logging import bar_msg +from arho_feature_template.qgis_plugin_tools.tools.settings import parse_value + +LOGGER = logging.getLogger("LandUsePlugin") + +PG_CONNECTIONS = "PostgreSQL/connections" +QGS_SETTINGS_PSYCOPG2_PARAM_MAP = { + "database": "dbname", + "host": "host", + "password": "password", + "port": "port", + "username": "user", + "sslmode": "sslmode", +} +QGS_SETTINGS_SSL_MODE_TO_POSTGRES = { + "SslDisable": "disable", + "SslAllow": "allow", + "SslPrefer": "prefer", + "SslRequire": "require", + "SslVerifyCa": "verify-ca", + "SslVerifyFull": "verify-full", +} + + +def get_existing_database_connections() -> set: + """ + Retrieve the list of existing database connections from QGIS settings. + + :return: A set of available PostgreSQL connection names. + """ + s = QSettings() + s.beginGroup(PG_CONNECTIONS) + keys = s.allKeys() + s.endGroup() + + connections = {key.split("/")[0] for key in keys if "/" in key} + LOGGER.debug(f"Available database connections: {connections}") # noqa: G004 + + return connections + + +def get_db_connection_params(con_name) -> dict: + s = QSettings() + s.beginGroup(f"{PG_CONNECTIONS}/{con_name}") + + auth_cfg_id = parse_value(s.value("authcfg")) + username_saved = parse_value(s.value("saveUsername")) + pwd_saved = parse_value(s.value("savePassword")) + # sslmode = parse_value(s.value("sslmode")) + + params = {} + + for qgs_key, psyc_key in QGS_SETTINGS_PSYCOPG2_PARAM_MAP.items(): + if psyc_key != "sslmode": + params[psyc_key] = parse_value(s.value(qgs_key)) + else: + params[psyc_key] = QGS_SETTINGS_SSL_MODE_TO_POSTGRES[parse_value(s.value(qgs_key))] + + s.endGroup() + # username or password might have to be asked separately + if not username_saved: + params["user"] = None + + if not pwd_saved: + params["password"] = None + + if auth_cfg_id is not None and auth_cfg_id != "": + # Auth config is being used to store the username and password + auth_config = QgsAuthMethodConfig() + # noinspection PyArgumentList + QgsApplication.authManager().loadAuthenticationConfig(auth_cfg_id, auth_config, True) + + if auth_config.isValid(): + params["user"] = auth_config.configMap().get("username") + params["password"] = auth_config.configMap().get("password") + else: + msg = "Auth config error occurred while fetching database connection parameters" + raise AuthConfigException( + msg, + bar_msg=bar_msg(f"Check auth config with id: {auth_cfg_id}"), + ) + + return params + + +def check_credentials(conn_params: dict) -> None: + """ + Checks whether the username and password are present in the connection parameters. + If not, prompt the user to enter the credentials via a dialog. + + :param conn_params: Connection parameters (dictionary). + """ + if not conn_params["user"] or not conn_params["password"]: + LOGGER.info("No username and/or password found. Asking user for credentials.") + + # Show dialog to ask for user credentials + ask_credentials_dlg = DbAskCredentialsDialog() + result = ask_credentials_dlg.exec_() + + if result == QDialog.Accepted: + conn_params["user"] = ask_credentials_dlg.userLineEdit.text() + conn_params["password"] = ask_credentials_dlg.pwdLineEdit.text() + else: + ask_credentials_dlg.close() + QMessageBox.warning(None, "Authentication Required", "Cannot connect without username or password.") + return None From 72703bae4eed89b1f367b05e8d43bc70188399dd Mon Sep 17 00:00:00 2001 From: Lauri Kajan Date: Mon, 28 Oct 2024 21:48:14 +0200 Subject: [PATCH 11/12] Change to use ProviderRegistry --- arho_feature_template/core/exceptions.py | 7 +- arho_feature_template/gui/ask_credentials.py | 36 ----- arho_feature_template/gui/ask_credentials.ui | 66 --------- arho_feature_template/gui/load_plan_dialog.py | 139 +++++++----------- arho_feature_template/gui/load_plan_dialog.ui | 87 ++++++----- arho_feature_template/plugin.py | 5 +- arho_feature_template/utils/db_utils.py | 110 ++------------ 7 files changed, 122 insertions(+), 328 deletions(-) delete mode 100644 arho_feature_template/gui/ask_credentials.py delete mode 100644 arho_feature_template/gui/ask_credentials.ui diff --git a/arho_feature_template/core/exceptions.py b/arho_feature_template/core/exceptions.py index 7b77bfb..50b61b5 100644 --- a/arho_feature_template/core/exceptions.py +++ b/arho_feature_template/core/exceptions.py @@ -1,5 +1,2 @@ -from arho_feature_template.qgis_plugin_tools.tools.exceptions import QgsPluginException - - -class AuthConfigException(QgsPluginException): - pass +class UnexpectedNoneError(Exception): + """Internal QGIS errors that should not be happened""" diff --git a/arho_feature_template/gui/ask_credentials.py b/arho_feature_template/gui/ask_credentials.py deleted file mode 100644 index 5233949..0000000 --- a/arho_feature_template/gui/ask_credentials.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -from importlib import resources - -from qgis.PyQt import uic -from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QLineEdit - -# Load the .ui file path using importlib resources -ui_path = resources.files(__package__) / "ask_credentials.ui" - -# Use uic.loadUiType to load the UI definition from the .ui file -DbAskCredentialsDialogBase, _ = uic.loadUiType(ui_path) - - -class DbAskCredentialsDialog(QDialog, DbAskCredentialsDialogBase): # type: ignore - def __init__(self, parent: QDialog = None): - super().__init__(parent) - - # Set up the UI from the loaded .ui file - self.setupUi(self) - - # The UI elements defined in the .ui file - self.userLineEdit: QLineEdit = self.findChild(QLineEdit, "userLineEdit") - self.pwdLineEdit: QLineEdit = self.findChild(QLineEdit, "pwdLineEdit") - self.buttonBox: QDialogButtonBox = self.findChild(QDialogButtonBox, "buttonBox") - - # Connect the OK and Cancel buttons to their respective functions - self.buttonBox.accepted.connect(self.accept) - self.buttonBox.rejected.connect(self.reject) - - def get_credentials(self) -> tuple[str, str]: - """ - Returns the entered username and password. - :return: Tuple (username, password) - """ - return self.userLineEdit.text(), self.pwdLineEdit.text() diff --git a/arho_feature_template/gui/ask_credentials.ui b/arho_feature_template/gui/ask_credentials.ui deleted file mode 100644 index cf42490..0000000 --- a/arho_feature_template/gui/ask_credentials.ui +++ /dev/null @@ -1,66 +0,0 @@ - - - DbAskCredentialsDialogBase - - - - 0 - 0 - 400 - 200 - - - - Käyttäjän autentikaatio - - - - - - - - Käyttäjä: - - - - - - - Käyttäjä... - - - - - - - Salasana: - - - - - - - Salasana... - - - QLineEdit::Password - - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - diff --git a/arho_feature_template/gui/load_plan_dialog.py b/arho_feature_template/gui/load_plan_dialog.py index 250d74b..8cbf984 100644 --- a/arho_feature_template/gui/load_plan_dialog.py +++ b/arho_feature_template/gui/load_plan_dialog.py @@ -1,13 +1,12 @@ from importlib import resources -import psycopg2 +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, QLineEdit, QMessageBox, QPushButton, QTableView +from qgis.PyQt.QtWidgets import QComboBox, QDialog, QDialogButtonBox, QLineEdit, QMessageBox, QPushButton, QTableView -from arho_feature_template.gui.ask_credentials import DbAskCredentialsDialog -from arho_feature_template.utils.db_utils import get_db_connection_params +from arho_feature_template.core.exceptions import UnexpectedNoneError ui_path = resources.files(__package__) / "load_plan_dialog.ui" @@ -34,66 +33,67 @@ def filterAcceptsRow(self, source_row, source_parent): # noqa: N802 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) - uic.loadUi(ui_path, self) + self._selected_plan_id = None - self.connectionComboBox: QComboBox = self.findChild(QComboBox, "connectionComboBox") - self.planTableView: QTableView = self.findChild(QTableView, "planTableView") - self.okButton: QPushButton = self.findChild(QPushButton, "okButton") + self.buttonBox.rejected.connect(self.reject) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False) - self.searchLineEdit: QLineEdit = self.findChild(QLineEdit, "searchLineEdit") - self.searchLineEdit.setPlaceholderText("Etsi kaavoja...") + self.push_button_load.clicked.connect(self.load_plans) + self.searchLineEdit.textChanged.connect(self.filter_plans) self.connectionComboBox.addItems(connections) - self.connectionComboBox.currentIndexChanged.connect(self.load_plans) - self.okButton.clicked.connect(self.accept) - - self.okButton.setEnabled(False) - - self.filterProxyModel = PlanFilterProxyModel(self) + 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) - self.searchLineEdit.textChanged.connect(self.filter_plans) - - self.selected_plan_id = None 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 - cursor = None - conn = None + 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: - conn_params = get_db_connection_params(selected_connection) - - if not conn_params.get("user") or not conn_params.get("password"): - # Trigger dialog to ask for missing credentials - dialog = DbAskCredentialsDialog(self) - dialog.rejected.connect(self.reject) - if dialog.exec() == QDialog.Accepted: - user, password = dialog.get_credentials() - conn_params["user"] = user - conn_params["password"] = password - - conn = psycopg2.connect( - host=conn_params["host"], - port=conn_params["port"], - dbname=conn_params["dbname"], - user=conn_params["user"], - password=conn_params["password"], - sslmode=conn_params["sslmode"], - ) - - cursor = conn.cursor() - - cursor.execute(""" + connection = postgres_provider_metadata.createConnection(selected_connection) + plans = connection.executeSql(""" SELECT p.id, p.producers_plan_identifier, @@ -111,46 +111,12 @@ def load_plans(self): ON p.plan_type_id = pt.id; """) - plans = cursor.fetchall() - - model = QStandardItemModel(len(plans), 5) - model.setHorizontalHeaderLabels( - [ - "ID", - "Tuottajan kaavatunnus", - "Nimi", - "Kaavan elinkaaren tila", - "Kaavalaji", - ] - ) - - for row_idx, plan in enumerate(plans): - model.setItem(row_idx, 0, QStandardItem(str(plan[0]))) # id - model.setItem(row_idx, 1, QStandardItem(str(plan[1]))) # producer_plan_identifier - model.setItem(row_idx, 2, QStandardItem(str(plan[2]))) # name_fin - model.setItem(row_idx, 3, QStandardItem(str(plan[3]))) # lifecycle_status_fin - model.setItem(row_idx, 4, QStandardItem(str(plan[4]))) # plan_type_fin - - self.filterProxyModel.setSourceModel(model) - - self.planTableView.setSelectionMode(QTableView.SingleSelection) - self.planTableView.setSelectionBehavior(QTableView.SelectRows) - - self.planTableView.selectionModel().selectionChanged.connect(self.on_selection_changed) - - except ValueError as ve: - QMessageBox.critical(self, "Connection Error", str(ve)) - self.planTableView.setModel(QStandardItemModel()) + 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.planTableView.setModel(QStandardItemModel()) - - finally: - if cursor: - cursor.close() - if conn: - conn.close() + self.model.removeRows(0, self.model.rowCount()) def filter_plans(self): search_text = self.searchLineEdit.text() @@ -163,16 +129,17 @@ def filter_plans(self): 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() - self.okButton.setEnabled(True) + self._selected_plan_id = self.planTableView.model().index(selected_row, 0).data() + ok_button.setEnabled(True) else: - self.selected_plan_id = None - self.okButton.setEnabled(False) + 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 + 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 index d0e1a8f..0a0c82d 100644 --- a/arho_feature_template/gui/load_plan_dialog.ui +++ b/arho_feature_template/gui/load_plan_dialog.ui @@ -2,8 +2,13 @@ LoadPlanDialog - - Avaa kaava + + + 0 + 0 + 800 + 600 + @@ -11,9 +16,10 @@ 600 + + Avaa kaava + - - @@ -22,16 +28,26 @@ - - - - 0 - 0 - - - + + + + + + 0 + 0 + + + + + + + + Lataa kaavat + + + + - @@ -40,9 +56,14 @@ QSizePolicy::Expanding + + + 0 + 0 + + - @@ -52,18 +73,17 @@ - - Etsi kaavoja... - 0 0 + + Etsi kaavoja... + - @@ -72,10 +92,14 @@ QSizePolicy::Expanding + + + 0 + 0 + + - - @@ -91,9 +115,8 @@ 1 - + - @@ -102,20 +125,18 @@ QSizePolicy::Expanding + + + 0 + 0 + + - - - - - OK - - - - 0 - 0 - + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 295290b..fc34cc2 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -15,7 +15,7 @@ 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_connections +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: @@ -174,7 +174,8 @@ def add_new_plan(self): def load_existing_land_use_plan(self) -> None: """Open existing land use plan.""" - connections = get_existing_database_connections() + + connections = get_existing_database_connection_names() if not connections: QMessageBox.critical(None, "Error", "No database connections found.") diff --git a/arho_feature_template/utils/db_utils.py b/arho_feature_template/utils/db_utils.py index e28d081..b8f67a0 100644 --- a/arho_feature_template/utils/db_utils.py +++ b/arho_feature_template/utils/db_utils.py @@ -2,115 +2,25 @@ import logging -from qgis.core import QgsApplication, QgsAuthMethodConfig -from qgis.PyQt.QtCore import QSettings -from qgis.PyQt.QtWidgets import QDialog, QMessageBox +from qgis.core import QgsProviderRegistry -from arho_feature_template.core.exceptions import AuthConfigException -from arho_feature_template.gui.ask_credentials import DbAskCredentialsDialog -from arho_feature_template.qgis_plugin_tools.tools.custom_logging import bar_msg -from arho_feature_template.qgis_plugin_tools.tools.settings import parse_value +from arho_feature_template.core.exceptions import UnexpectedNoneError LOGGER = logging.getLogger("LandUsePlugin") -PG_CONNECTIONS = "PostgreSQL/connections" -QGS_SETTINGS_PSYCOPG2_PARAM_MAP = { - "database": "dbname", - "host": "host", - "password": "password", - "port": "port", - "username": "user", - "sslmode": "sslmode", -} -QGS_SETTINGS_SSL_MODE_TO_POSTGRES = { - "SslDisable": "disable", - "SslAllow": "allow", - "SslPrefer": "prefer", - "SslRequire": "require", - "SslVerifyCa": "verify-ca", - "SslVerifyFull": "verify-full", -} - -def get_existing_database_connections() -> set: +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. """ - s = QSettings() - s.beginGroup(PG_CONNECTIONS) - keys = s.allKeys() - s.endGroup() - - connections = {key.split("/")[0] for key in keys if "/" in key} - LOGGER.debug(f"Available database connections: {connections}") # noqa: G004 - - return connections - - -def get_db_connection_params(con_name) -> dict: - s = QSettings() - s.beginGroup(f"{PG_CONNECTIONS}/{con_name}") - - auth_cfg_id = parse_value(s.value("authcfg")) - username_saved = parse_value(s.value("saveUsername")) - pwd_saved = parse_value(s.value("savePassword")) - # sslmode = parse_value(s.value("sslmode")) - - params = {} - - for qgs_key, psyc_key in QGS_SETTINGS_PSYCOPG2_PARAM_MAP.items(): - if psyc_key != "sslmode": - params[psyc_key] = parse_value(s.value(qgs_key)) - else: - params[psyc_key] = QGS_SETTINGS_SSL_MODE_TO_POSTGRES[parse_value(s.value(qgs_key))] - - s.endGroup() - # username or password might have to be asked separately - if not username_saved: - params["user"] = None - - if not pwd_saved: - params["password"] = None - - if auth_cfg_id is not None and auth_cfg_id != "": - # Auth config is being used to store the username and password - auth_config = QgsAuthMethodConfig() - # noinspection PyArgumentList - QgsApplication.authManager().loadAuthenticationConfig(auth_cfg_id, auth_config, True) - - if auth_config.isValid(): - params["user"] = auth_config.configMap().get("username") - params["password"] = auth_config.configMap().get("password") - else: - msg = "Auth config error occurred while fetching database connection parameters" - raise AuthConfigException( - msg, - bar_msg=bar_msg(f"Check auth config with id: {auth_cfg_id}"), - ) - - return params - - -def check_credentials(conn_params: dict) -> None: - """ - Checks whether the username and password are present in the connection parameters. - If not, prompt the user to enter the credentials via a dialog. - - :param conn_params: Connection parameters (dictionary). - """ - if not conn_params["user"] or not conn_params["password"]: - LOGGER.info("No username and/or password found. Asking user for credentials.") - # Show dialog to ask for user credentials - ask_credentials_dlg = DbAskCredentialsDialog() - result = ask_credentials_dlg.exec_() + 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 - if result == QDialog.Accepted: - conn_params["user"] = ask_credentials_dlg.userLineEdit.text() - conn_params["password"] = ask_credentials_dlg.pwdLineEdit.text() - else: - ask_credentials_dlg.close() - QMessageBox.warning(None, "Authentication Required", "Cannot connect without username or password.") - return None + return list(postgres_provider_metadata.dbConnections(False)) From 05f33def98d3f6f13b25d89bf77889707d386310 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Tue, 29 Oct 2024 10:05:32 +0200 Subject: [PATCH 12/12] feat: implement remove plan regulation button in template attribute form --- arho_feature_template/gui/plan_regulation_group_widget.py | 7 +++++++ arho_feature_template/gui/template_attribute_form.py | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/arho_feature_template/gui/plan_regulation_group_widget.py b/arho_feature_template/gui/plan_regulation_group_widget.py index 3131dcf..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,9 +65,13 @@ 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 diff --git a/arho_feature_template/gui/template_attribute_form.py b/arho_feature_template/gui/template_attribute_form.py index 1c4ec3b..23b10e7 100644 --- a/arho_feature_template/gui/template_attribute_form.py +++ b/arho_feature_template/gui/template_attribute_form.py @@ -65,10 +65,15 @@ def remove_spacer(self): 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() for config in self.available_plan_regulation_group_configs: