From dd61201d8791bc1fa0965a261d12758cbb6ca5ff Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Thu, 16 Jan 2025 16:35:09 +0200 Subject: [PATCH 1/6] add plan feature editing - add a custom QgsMapTool for selecting a plan feature to modify - construct PlanFeatureForm from an existing feature - update feature attributes, including regulation group associations - fix duplicate PlanManager creation in plugin.py --- arho_feature_template/core/plan_manager.py | 77 +++++++++--- arho_feature_template/gui/tools/__init__.py | 0 .../gui/tools/inspect_plan_features_tool.py | 116 ++++++++++++++++++ arho_feature_template/plugin.py | 18 ++- .../project/layers/plan_layers.py | 50 +++++++- 5 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 arho_feature_template/gui/tools/__init__.py create mode 100644 arho_feature_template/gui/tools/inspect_plan_features_tool.py diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py index c82ccb3..ad6d7a1 100644 --- a/arho_feature_template/core/plan_manager.py +++ b/arho_feature_template/core/plan_manager.py @@ -22,6 +22,7 @@ from arho_feature_template.gui.dialogs.plan_feature_form import PlanFeatureForm from arho_feature_template.gui.dialogs.serialize_plan import SerializePlan from arho_feature_template.gui.docks.new_feature_dock import NewFeatureDock +from arho_feature_template.gui.tools.inspect_plan_features_tool import InspectPlanFeatures from arho_feature_template.project.layers.code_layers import PlanRegulationGroupTypeLayer from arho_feature_template.project.layers.plan_layers import ( LandUseAreaLayer, @@ -55,10 +56,10 @@ FEATURE_LAYER_NAME_TO_CLASS_MAP: dict[str, type[PlanFeatureLayer]] = { LandUsePointLayer.name: LandUsePointLayer, - OtherAreaLayer.name: OtherAreaLayer, OtherPointLayer.name: OtherPointLayer, - LandUseAreaLayer.name: LandUseAreaLayer, LineLayer.name: LineLayer, + OtherAreaLayer.name: OtherAreaLayer, + LandUseAreaLayer.name: LandUseAreaLayer, } @@ -95,6 +96,19 @@ def __init__(self): self.feature_digitize_map_tool = None self.initialize_feature_digitize_map_tool() + # Initialize plan feature inspect tool + self.inspect_plan_feature_tool = InspectPlanFeatures( + iface.mapCanvas(), list(FEATURE_LAYER_NAME_TO_CLASS_MAP.values()) + ) + self.inspect_plan_feature_tool.edit_feature_requested.connect(self.edit_plan_feature) + + def toggle_identify_plan_features(self, activate: bool): # noqa: FBT001 + if activate: + self.previous_map_tool = iface.mapCanvas().mapTool() + iface.mapCanvas().setMapTool(self.inspect_plan_feature_tool) + else: + iface.mapCanvas().setMapTool(self.previous_map_tool) + def initialize_feature_digitize_map_tool(self, layer: QgsVectorLayer | None = None): # Get matcing capture mode for given layer if layer is None: @@ -200,6 +214,18 @@ def _plan_feature_geom_digitized(self, feature: QgsFeature): if attribute_form.exec_(): save_plan_feature(attribute_form.model) + def edit_plan_feature(self, feature: QgsFeature, layer_name: str): + layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP[layer_name] + plan_feature = layer_class.model_from_feature(feature) + + # Geom editing handled with basic QGIS vertex editing? + title = plan_feature.name if plan_feature.name else layer_name + attribute_form = PlanFeatureForm( + plan_feature, title, [*self.regulation_group_libraries, regulation_group_library_from_active_plan()] + ) + if attribute_form.exec_(): + save_plan_feature(attribute_form.model) + def set_active_plan(self, plan_id: str | None): """Update the project layers based on the selected land use plan. @@ -342,6 +368,17 @@ def _save_feature(feature: QgsFeature, layer: QgsVectorLayer, id_: int | None, e layer.commitChanges(stopEditing=False) +def _delete_feature(feature: QgsFeature, layer: QgsVectorLayer, delete_text: str = ""): + if not layer.isEditable(): + layer.startEditing() + layer.beginEditCommand(delete_text) + + layer.deleteFeature(feature.id()) + + layer.endEditCommand() + layer.commitChanges(stopEditing=False) + + def save_plan(plan_data: Plan) -> QgsFeature: plan_layer = PlanLayer.get_from_project() in_edit_mode = plan_layer.isEditable() @@ -372,29 +409,41 @@ def save_plan(plan_data: Plan) -> QgsFeature: def save_plan_feature(plan_feature: PlanFeature, plan_id: str | None = None) -> QgsFeature: - if not plan_feature.layer_name: + layer_name = plan_feature.layer_name + if not layer_name: msg = "Cannot save plan feature without a target layer" raise ValueError(msg) - layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP.get(plan_feature.layer_name) + layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP.get(layer_name) if not layer_class: - msg = f"Could not find plan feature layer class for layer name {plan_feature.layer_name}" + msg = f"Could not find plan feature layer class for layer name {layer_name}" raise ValueError(msg) feature = layer_class.feature_from_model(plan_feature, plan_id) layer = layer_class.get_from_project() + editing = plan_feature.id_ is not None _save_feature( feature=feature, layer=layer, id_=plan_feature.id_, - edit_text="Kaavakohteen lisäys" if plan_feature.id_ is None else "Kaavakohteen muokkaus", + edit_text="Kaavakohteen muokkaus" if editing else "Kaavakohteen lisäys", ) - # Handle regulation groups - if plan_feature.regulation_groups: - for group in plan_feature.regulation_groups: - regulation_group_feature = save_regulation_group(group) - save_regulation_group_association(regulation_group_feature["id"], plan_feature.layer_name, feature["id"]) + # Check for deleted regulation groups + if editing: + for association in RegulationGroupAssociationLayer.get_dangling_associations( + plan_feature.regulation_groups, feature["id"], layer_name + ): + _delete_feature( + association, + RegulationGroupAssociationLayer.get_from_project(), + "Kaavamääräysryhmän assosiaation poisto", + ) + + # Save regulation groups + for group in plan_feature.regulation_groups: + regulation_group_feature = save_regulation_group(group) + save_regulation_group_association(regulation_group_feature["id"], layer_name, feature["id"]) return feature @@ -423,14 +472,14 @@ def save_regulation_group_as_config(regulation_group: RegulationGroup): pass -def save_regulation_group_association(regulation_group_id: str, layer_name: str, feature_id: str) -> QgsFeature: +def save_regulation_group_association(regulation_group_id: str, layer_name: str, feature_id: str): feature = RegulationGroupAssociationLayer.feature_from(regulation_group_id, layer_name, feature_id) + if not feature: + return layer = RegulationGroupAssociationLayer.get_from_project() _save_feature(feature=feature, layer=layer, id_=None, edit_text="Kaavamääräysryhmän assosiaation lisäys") - return feature - def save_regulation(regulation: Regulation) -> QgsFeature: feature = PlanRegulationLayer.feature_from_model(regulation) diff --git a/arho_feature_template/gui/tools/__init__.py b/arho_feature_template/gui/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/arho_feature_template/gui/tools/inspect_plan_features_tool.py b/arho_feature_template/gui/tools/inspect_plan_features_tool.py new file mode 100644 index 0000000..06edb64 --- /dev/null +++ b/arho_feature_template/gui/tools/inspect_plan_features_tool.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from qgis.core import QgsFeature, QgsFeatureRequest, QgsGeometry, QgsPointXY, QgsSpatialIndex, QgsVectorLayer +from qgis.gui import QgsMapCanvas, QgsMapMouseEvent, QgsMapTool, QgsRubberBand +from qgis.PyQt.QtCore import QPoint, QTimer, pyqtSignal +from qgis.PyQt.QtGui import QColor +from qgis.PyQt.QtWidgets import QMenu + +if TYPE_CHECKING: + from arho_feature_template.project.layers.plan_layers import PlanFeatureLayer + + +class InspectPlanFeatures(QgsMapTool): + CLICK_POS_TOLERANCE = 2 + edit_feature_requested = pyqtSignal(QgsFeature, str) + + def __init__(self, canvas: QgsMapCanvas, layer_classes: list[PlanFeatureLayer]): + super().__init__(canvas) + self.canvas = canvas + + self.layer_classes = layer_classes + self.layers = [cls.get_from_project() for cls in layer_classes] + self.spatial_indexes: dict[str, QgsSpatialIndex] = {} + + self.highlighted: tuple[QgsFeature, str] | None = None + self.highlight_rubber_band: QgsRubberBand | None = None + + def activate(self): + super().activate() + self.rebuild_spatial_indexes() + + def rebuild_spatial_indexes(self): + for layer in self.layers: + self.spatial_indexes[layer.id()] = QgsSpatialIndex(layer.getFeatures()) + + def create_highlight_rubberband(self, feature: QgsFeature, layer: QgsVectorLayer): + self.highlight_rubber_band = QgsRubberBand(self.canvas, layer.geometryType()) + self.highlight_rubber_band.setToGeometry(feature.geometry(), layer) + self.highlight_rubber_band.setColor(QColor(255, 0, 0, 100)) # Semi-transparent red + self.highlight_rubber_band.setWidth(2) + + def _clear_highlight(self): + if self.highlight_rubber_band: + self.canvas.scene().removeItem(self.highlight_rubber_band) + self.highlight_rubber_band = None + + def check_menu_selections(self, menu: QMenu): + # If no menu item is selected, clear highlights/rubberbands + if menu.activeAction() is None: + self._clear_highlight() + + def highlight_feature(self, feature: QgsFeature, layer: QgsVectorLayer): + self._clear_highlight() + self.create_highlight_rubberband(feature, layer) + + def canvasReleaseEvent(self, event: QgsMapMouseEvent): # noqa: N802 + point = self.toMapCoordinates(event.pos()) + nearby_features = self.query_nearby_features(point) + + if nearby_features: + self.create_menu(event.pos(), nearby_features) + + def query_nearby_features(self, point: QgsPointXY) -> dict[QgsVectorLayer, list[QgsFeature]]: + """Query all feature layers for features near (within `CLICK_POS_TOLERANCE`) the clicked point.""" + results = {} + for layer in self.layers: + spatial_index = self.spatial_indexes.get(layer.id()) + if spatial_index is None: + continue + + # Buffer around the clicked point + tolerance_geom = QgsGeometry.fromPointXY(point).buffer(self.CLICK_POS_TOLERANCE, 1) + candidate_ids = spatial_index.intersects(tolerance_geom.boundingBox()) + request = QgsFeatureRequest().setFilterFids(candidate_ids) + features = [feat for feat in layer.getFeatures(request) if feat.geometry().intersects(tolerance_geom)] + if features: + results[layer] = features + + return results + + def create_menu(self, click_pos: QPoint, nearby_features: dict[QgsVectorLayer, list[QgsFeature]]): + """Create a menu with feature layer names at the click position.""" + menu = QMenu("Avaa kaavakohteen tiedot") + for layer, features in nearby_features.items(): + for feature in features: + menu_text = layer.name() + # feat_name = feature["name"]["fin"] + # if feat_name: + # menu_text += f" — {feat_name}" + action = menu.addAction(menu_text) + action.triggered.connect(partial(self.edit_feature_requested.emit, feature, layer.name())) + action.hovered.connect(partial(self.highlight_feature, feature, layer)) + if not menu.isEmpty(): + self.show_menu(menu, click_pos) + + def show_menu(self, menu: QMenu, screen_pos: QPoint): + self._create_timer(menu) + menu.aboutToHide.connect(self.close_menu) + menu.exec_(self.canvas.mapToGlobal(screen_pos)) + + def close_menu(self): + self._clear_highlight() + self._destroy_timer() + + def _create_timer(self, menu: QMenu): + self.check_menu_selections_timer = QTimer(self) + self.check_menu_selections_timer.timeout.connect(lambda: self.check_menu_selections(menu)) + self.check_menu_selections_timer.start(100) # 0.1 seconds interval + + def _destroy_timer(self): + self.check_menu_selections_timer.timeout.disconnect() + self.check_menu_selections_timer.deleteLater() + self.check_menu_selections_timer = None diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 579ef70..0d0ef8e 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -29,7 +29,6 @@ class Plugin: def __init__(self) -> None: setup_logger(Plugin.name) self.digitizing_tool = None - self.plan_manager = PlanManager() # initialize locale locale, file_path = setup_translation() @@ -126,18 +125,20 @@ def add_action( return action def initGui(self) -> None: # noqa N802 - self.plan_manager = PlanManager() - # 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 + self.plan_manager = PlanManager() iface.addDockWidget(Qt.RightDockWidgetArea, self.plan_manager.new_feature_dock) + self.plan_manager.new_feature_dock.setUserVisible(False) + self.plan_manager.new_feature_dock.visibilityChanged.connect(self.dock_visibility_changed) iface.mapCanvas().mapToolSet.connect(self.plan_manager.digitize_map_tool.deactivate) self.validation_dock = ValidationDock() iface.addDockWidget(Qt.RightDockWidgetArea, self.validation_dock) + self.validation_dock.setUserVisible(False) self.validation_dock.visibilityChanged.connect(self.validation_dock_visibility_changed) @@ -209,6 +210,17 @@ def initGui(self) -> None: # noqa N802 status_tip="Muokkaa pluginin asetuksia", ) + self.identify_plan_features_action = self.add_action( + text="Muokkaa kohteita", + toggled_callback=self.plan_manager.toggle_identify_plan_features, + add_to_menu=False, + add_to_toolbar=True, + checkable=True, + ) + self.plan_manager.inspect_plan_feature_tool.deactivated.connect( + lambda: self.identify_plan_features_action.setChecked(False) + ) + def add_new_plan(self): self.plan_manager.add_new_plan() diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index 1354caa..e374be3 100644 --- a/arho_feature_template/project/layers/plan_layers.py +++ b/arho_feature_template/project/layers/plan_layers.py @@ -88,13 +88,12 @@ def feature_from_model(cls, model: Plan) -> QgsFeature: class PlanFeatureLayer(AbstractPlanLayer): @classmethod def feature_from_model(cls, model: PlanFeature, plan_id: str | None = None) -> QgsFeature: - layer = cls.get_from_project() - if not model.geom: message = "Plan feature must have a geometry to be added to the layer" raise ValueError(message) - feature = QgsVectorLayerUtils.createFeature(layer, model.geom) + feature = cls.initialize_feature_from_model(model) + feature.setGeometry(model.geom) feature["name"] = {"fin": model.name if model.name else ""} feature["type_of_underground_id"] = model.type_of_underground_id feature["description"] = {"fin": model.description if model.description else ""} @@ -106,6 +105,22 @@ def feature_from_model(cls, model: PlanFeature, plan_id: str | None = None) -> Q return feature + @classmethod + def model_from_feature(cls, feature: QgsFeature) -> PlanFeature: + return PlanFeature( + geom=feature.geometry(), + type_of_underground_id=feature["type_of_underground_id"], + layer_name=cls.get_from_project().name(), + name=feature["name"]["fin"], + description=feature["description"]["fin"], + regulation_groups=[ + RegulationGroupLayer.model_from_feature(RegulationGroupLayer.get_feature_by_id(group_id)) + for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + ], + plan_id=feature["plan_id"], + id_=feature["id"], + ) + class LandUsePointLayer(PlanFeatureLayer): name = "Maankäytön kohteet" @@ -192,13 +207,18 @@ class RegulationGroupAssociationLayer(AbstractPlanLayer): } @classmethod - def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str) -> QgsFeature: + def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str) -> QgsFeature | None: layer = cls.get_from_project() + attribute = cls.layer_name_to_attribute_map.get(layer_name) + + # Check if association exists to avoid duplicate assocations + for feature in layer.getFeatures(): + if feature["plan_regulation_group_id"] == regulation_group_id and feature[attribute] == feature_id: + return None feature = QgsVectorLayerUtils.createFeature(layer) feature["plan_regulation_group_id"] = regulation_group_id - attribute = cls.layer_name_to_attribute_map.get(layer_name) if not attribute: msg = f"Unrecognized layer name given for saving regulation group association: {layer_name}" raise ValueError(msg) @@ -206,6 +226,26 @@ def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str return feature + @classmethod + def get_associations_for_feature(cls, feature_id: str, layer_name: str) -> list[QgsFeature]: + attribute = cls.layer_name_to_attribute_map.get(layer_name) + return [feature for feature in cls.get_features() if feature[attribute] == feature_id] + + @classmethod + def get_group_ids_for_feature(cls, feature_id: str, layer_name: str) -> list[str]: + attribute = cls.layer_name_to_attribute_map.get(layer_name) + return [ + feature["plan_regulation_group_id"] for feature in cls.get_features() if feature[attribute] == feature_id + ] + + @classmethod + def get_dangling_associations( + cls, groups: list[RegulationGroup], feature_id: str, layer_name: str + ) -> list[QgsFeature]: + associations = RegulationGroupAssociationLayer.get_associations_for_feature(feature_id, layer_name) + updated_group_ids = [group.id_ for group in groups] + return [assoc for assoc in associations if assoc["plan_regulation_group_id"] not in updated_group_ids] + class PlanRegulationLayer(AbstractPlanLayer): name = "Kaavamääräys" From e5fed5c5e5a4108f2a5f383bf71de7da9d9474e8 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Thu, 16 Jan 2025 17:30:41 +0200 Subject: [PATCH 2/6] removed plan regulation type selection widget --- .../plan_regulation_group_widget.py | 41 ++----------------- .../plan_regulation_group_widget.ui | 37 +---------------- .../gui/dialogs/plan_attribute_form.py | 2 +- .../gui/dialogs/plan_feature_form.py | 7 ++-- .../project/layers/code_layers.py | 24 +++++++++-- 5 files changed, 30 insertions(+), 81 deletions(-) diff --git a/arho_feature_template/gui/components/plan_regulation_group_widget.py b/arho_feature_template/gui/components/plan_regulation_group_widget.py index 8dc18b2..b90ab93 100644 --- a/arho_feature_template/gui/components/plan_regulation_group_widget.py +++ b/arho_feature_template/gui/components/plan_regulation_group_widget.py @@ -15,8 +15,6 @@ if TYPE_CHECKING: from qgis.PyQt.QtWidgets import QFormLayout, QFrame, QLabel, QLineEdit, QPushButton - from arho_feature_template.gui.components.code_combobox import CodeComboBox - ui_path = resources.files(__package__) / "plan_regulation_group_widget.ui" FormClass, _ = uic.loadUiType(ui_path) @@ -26,11 +24,7 @@ class RegulationGroupWidget(QWidget, FormClass): # type: ignore delete_signal = pyqtSignal(QWidget) - def __init__( - self, - regulation_group_data: RegulationGroup, - general_regulation: bool = False, # noqa: FBT001, FBT002 - ): + def __init__(self, regulation_group_data: RegulationGroup, layer_name: str): super().__init__() self.setupUi(self) @@ -40,40 +34,15 @@ def __init__( self.short_name: QLineEdit self.short_name_label: QLabel self.del_btn: QPushButton - self.type_of_regulation_group_label: QLabel - self.type_of_regulation_group: CodeComboBox self.regulation_group_details_layout: QFormLayout - # NOTE: Maybe user input is not needed and wanted for type of plan regulation group and it would be defined - # by the plan feature directly (and hidden from user) # INIT - self.is_general_regulation = general_regulation self.regulation_group_data = regulation_group_data self.regulation_widgets: list[RegulationWidget] = [ self.add_regulation_widget(regulation) for regulation in self.regulation_group_data.regulations ] - # If regulation group type code is defined, delete selection for user - if self.is_general_regulation: - regulation_group_data.type_code_id = PlanRegulationGroupTypeLayer.get_id_of_regulation_type( - "generalRegulations" - ) - # Remove short name row - self.regulation_group_details_layout.removeWidget(self.short_name_label) - self.regulation_group_details_layout.removeWidget(self.short_name) - self.short_name_label.deleteLater() - self.short_name.deleteLater() - - if regulation_group_data.type_code_id: - # Remove type of plan regulation group row - self.regulation_group_details_layout.removeWidget(self.type_of_regulation_group_label) - self.regulation_group_details_layout.removeWidget(self.type_of_regulation_group) - self.type_of_regulation_group_label.deleteLater() - self.type_of_regulation_group.deleteLater() - else: - self.type_of_regulation_group.populate_from_code_layer(PlanRegulationGroupTypeLayer) - self.type_of_regulation_group.removeItem(0) # Remove NULL from combobox as underground data is required - + regulation_group_data.type_code_id = PlanRegulationGroupTypeLayer.get_id_by_feature_layer_name(layer_name) self.name.setText(self.regulation_group_data.name if self.regulation_group_data.name else "") self.short_name.setText(self.regulation_group_data.short_name if self.regulation_group_data.short_name else "") self.del_btn.setIcon(QgsApplication.getThemeIcon("mActionDeleteSelected.svg")) @@ -92,11 +61,9 @@ def delete_regulation_widget(self, regulation_widget: RegulationWidget): def into_model(self) -> RegulationGroup: return RegulationGroup( - type_code_id=self.regulation_group_data.type_code_id - if self.regulation_group_data.type_code_id - else self.type_of_regulation_group.value(), + type_code_id=self.regulation_group_data.type_code_id, name=self.name.text(), - short_name=None if self.is_general_regulation else self.short_name.text(), + short_name=None if not self.short_name.text() else self.short_name.text(), color_code=self.regulation_group_data.color_code, regulations=[widget.into_model() for widget in self.regulation_widgets], id_=self.regulation_group_data.id_, diff --git a/arho_feature_template/gui/components/plan_regulation_group_widget.ui b/arho_feature_template/gui/components/plan_regulation_group_widget.ui index 094fb68..ef0691b 100644 --- a/arho_feature_template/gui/components/plan_regulation_group_widget.ui +++ b/arho_feature_template/gui/components/plan_regulation_group_widget.ui @@ -7,7 +7,7 @@ 0 0 420 - 160 + 129 @@ -117,34 +117,6 @@ - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - Tyyppi - - - - - - @@ -162,13 +134,6 @@ - - - CodeComboBox - QComboBox -
arho_feature_template.gui.components.code_combobox
-
-
diff --git a/arho_feature_template/gui/dialogs/plan_attribute_form.py b/arho_feature_template/gui/dialogs/plan_attribute_form.py index 718350e..3f54f60 100644 --- a/arho_feature_template/gui/dialogs/plan_attribute_form.py +++ b/arho_feature_template/gui/dialogs/plan_attribute_form.py @@ -106,7 +106,7 @@ def add_selected_plan_regulation_group(self, item: QTreeWidgetItem, column: int) self.add_plan_regulation_group(regulation_group) def add_plan_regulation_group(self, regulation_group: RegulationGroup): - regulation_group_widget = RegulationGroupWidget(regulation_group, general_regulation=True) + regulation_group_widget = RegulationGroupWidget(regulation_group, layer_name="Kaava") regulation_group_widget.delete_signal.connect(self.remove_plan_regulation_group) self._remove_spacer() self.plan_regulation_group_scrollarea_contents.layout().addWidget(regulation_group_widget) diff --git a/arho_feature_template/gui/dialogs/plan_feature_form.py b/arho_feature_template/gui/dialogs/plan_feature_form.py index fe6a6fc..94bd839 100644 --- a/arho_feature_template/gui/dialogs/plan_feature_form.py +++ b/arho_feature_template/gui/dialogs/plan_feature_form.py @@ -1,7 +1,7 @@ from __future__ import annotations from importlib import resources -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from qgis.PyQt import uic from qgis.PyQt.QtCore import Qt @@ -76,7 +76,8 @@ def __init__( # Initialize attributes from template self.plan_feature = plan_feature - self.layer_name = plan_feature.layer_name # Should have a layer name + self.layer_name = plan_feature.layer_name # Should always have a layer name + if plan_feature.name: self.feature_name.setText(plan_feature.name) if plan_feature.description: @@ -102,7 +103,7 @@ def add_selected_plan_regulation_group(self, item: QTreeWidgetItem, column: int) self.add_plan_regulation_group(regulation_group) def add_plan_regulation_group(self, definition: RegulationGroup): - regulation_group_widget = RegulationGroupWidget(definition) + regulation_group_widget = RegulationGroupWidget(definition, cast(str, self.layer_name)) regulation_group_widget.delete_signal.connect(self.remove_plan_regulation_group) self._remove_spacer() self.plan_regulation_group_scrollarea_contents.layout().addWidget(regulation_group_widget) diff --git a/arho_feature_template/project/layers/code_layers.py b/arho_feature_template/project/layers/code_layers.py index 2602909..0aeb926 100644 --- a/arho_feature_template/project/layers/code_layers.py +++ b/arho_feature_template/project/layers/code_layers.py @@ -1,5 +1,9 @@ from __future__ import annotations +from typing import ClassVar + +from qgis.core import QgsFeatureRequest + from arho_feature_template.project.layers import AbstractLayer @@ -25,11 +29,23 @@ class UndergroundTypeLayer(AbstractCodeLayer): class PlanRegulationGroupTypeLayer(AbstractCodeLayer): name = "Kaavamääräysryhmän tyyppi" + LAYER_NAME_TO_REGULATION_GROUP_TYPE_MAP: ClassVar[dict[str, str]] = { + "Kaava": "generalRegulations", + "Aluevaraus": "landUseRegulations", + "Osa-alue": "otherAreaRegulations", + "Viiva": "lineRegulations", + "Muu piste": "otherPointRegulations", + "Maankäytön kohteet": "landUseRegulations", + } + @classmethod - def get_id_of_regulation_type(cls, regulation_type: str) -> str | None: - for feature in cls.get_from_project().getFeatures(): - if feature["value"] == regulation_type: - return feature["id"] + def get_id_by_feature_layer_name(cls, layer_name: str) -> str | None: + regulation_group_type = cls.LAYER_NAME_TO_REGULATION_GROUP_TYPE_MAP.get(layer_name) + + request = QgsFeatureRequest().setFilterExpression(f"\"value\"='{regulation_group_type}'") + feature = next(cls.get_from_project().getFeatures(request), None) + if feature: + return feature["id"] return None From 4bc98ae7f972f7dfc187e8f7a6f54b1176860715 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Mon, 20 Jan 2025 10:40:51 +0200 Subject: [PATCH 3/6] remove spatial index building from InspectPlanFeatures tool --- .../gui/tools/inspect_plan_features_tool.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/arho_feature_template/gui/tools/inspect_plan_features_tool.py b/arho_feature_template/gui/tools/inspect_plan_features_tool.py index 06edb64..b9e8b2c 100644 --- a/arho_feature_template/gui/tools/inspect_plan_features_tool.py +++ b/arho_feature_template/gui/tools/inspect_plan_features_tool.py @@ -3,11 +3,12 @@ from functools import partial from typing import TYPE_CHECKING -from qgis.core import QgsFeature, QgsFeatureRequest, QgsGeometry, QgsPointXY, QgsSpatialIndex, QgsVectorLayer +from qgis.core import QgsFeature, QgsFeatureRequest, QgsPointXY, QgsRectangle, QgsSpatialIndex, QgsVectorLayer from qgis.gui import QgsMapCanvas, QgsMapMouseEvent, QgsMapTool, QgsRubberBand -from qgis.PyQt.QtCore import QPoint, QTimer, pyqtSignal +from qgis.PyQt.QtCore import QPoint, Qt, QTimer, pyqtSignal from qgis.PyQt.QtGui import QColor from qgis.PyQt.QtWidgets import QMenu +from qgis.utils import OverrideCursor if TYPE_CHECKING: from arho_feature_template.project.layers.plan_layers import PlanFeatureLayer @@ -66,18 +67,20 @@ def canvasReleaseEvent(self, event: QgsMapMouseEvent): # noqa: N802 def query_nearby_features(self, point: QgsPointXY) -> dict[QgsVectorLayer, list[QgsFeature]]: """Query all feature layers for features near (within `CLICK_POS_TOLERANCE`) the clicked point.""" results = {} - for layer in self.layers: - spatial_index = self.spatial_indexes.get(layer.id()) - if spatial_index is None: - continue - - # Buffer around the clicked point - tolerance_geom = QgsGeometry.fromPointXY(point).buffer(self.CLICK_POS_TOLERANCE, 1) - candidate_ids = spatial_index.intersects(tolerance_geom.boundingBox()) - request = QgsFeatureRequest().setFilterFids(candidate_ids) - features = [feat for feat in layer.getFeatures(request) if feat.geometry().intersects(tolerance_geom)] - if features: - results[layer] = features + tolerance_geom = QgsRectangle( + point.x() - self.CLICK_POS_TOLERANCE, + point.y() - self.CLICK_POS_TOLERANCE, + point.x() + self.CLICK_POS_TOLERANCE, + point.y() + self.CLICK_POS_TOLERANCE, + ) + request = QgsFeatureRequest() + request.setFilterRect(tolerance_geom) + request.setFlags(QgsFeatureRequest.Flag.ExactIntersect) + with OverrideCursor(Qt.WaitCursor): + for layer in self.layers: + features = list(layer.getFeatures(request)) + if features: + results[layer] = features return results From 01fa4f2ba54e38cd01aacb917edc5a7d00dc12b8 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Mon, 20 Jan 2025 14:32:14 +0200 Subject: [PATCH 4/6] add editing for plan layer - add toolbar action for editing plan attributes - modify plan model to allow Nones for all attributes - add set_value methods for CodeComboBoxes --- arho_feature_template/core/models.py | 16 ++--- arho_feature_template/core/plan_manager.py | 72 ++++++++++++------- .../gui/components/code_combobox.py | 42 ++++++++++- .../gui/dialogs/plan_attribute_form.py | 35 ++++++++- arho_feature_template/plugin.py | 10 +++ .../project/layers/plan_layers.py | 30 ++++++-- 6 files changed, 159 insertions(+), 46 deletions(-) diff --git a/arho_feature_template/core/models.py b/arho_feature_template/core/models.py index bf45afe..bb0eeb7 100644 --- a/arho_feature_template/core/models.py +++ b/arho_feature_template/core/models.py @@ -331,14 +331,14 @@ def from_config_data(cls, data: dict) -> PlanFeature: @dataclass class Plan: - name: str - description: str | None - plan_type_id: str - lifecycle_status_id: str - record_number: str | None - matter_management_identifier: str | None - permanent_plan_identifier: str | None - producers_plan_identifier: str | None + name: str | None = None + description: str | None = None + plan_type_id: str | None = None + lifecycle_status_id: str | None = None + record_number: str | None = None + matter_management_identifier: str | None = None + permanent_plan_identifier: str | None = None + producers_plan_identifier: str | None = None organisation_id: str | None = None general_regulations: list[RegulationGroup] = field(default_factory=list) geom: QgsGeometry | None = None diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py index ad6d7a1..1527ae9 100644 --- a/arho_feature_template/core/plan_manager.py +++ b/arho_feature_template/core/plan_manager.py @@ -11,6 +11,7 @@ from arho_feature_template.core.lambda_service import LambdaService from arho_feature_template.core.models import ( FeatureTemplateLibrary, + Plan, PlanFeature, RegulationGroupCategory, RegulationGroupLibrary, @@ -50,7 +51,7 @@ if TYPE_CHECKING: from qgis.core import QgsFeature - from arho_feature_template.core.models import Plan, Regulation, RegulationGroup + from arho_feature_template.core.models import Regulation, RegulationGroup logger = logging.getLogger(__name__) @@ -154,6 +155,22 @@ def add_new_plan(self): self.digitize_map_tool.setLayer(plan_layer) iface.mapCanvas().setMapTool(self.digitize_map_tool) + def edit_plan(self): + plan_layer = PlanLayer.get_from_project() + if not plan_layer: + return + + active_plan_id = QgsExpressionContextUtils.projectScope(QgsProject.instance()).variable("active_plan_id") + feature = PlanLayer.get_feature_by_id(active_plan_id) + if feature is None: + iface.messageBar().pushWarning("", "No active/open plan found!") + return + plan_model = PlanLayer.model_from_feature(feature) + + attribute_form = PlanAttributeForm(plan_model, self.regulation_group_libraries) + if attribute_form.exec_(): + feature = save_plan(attribute_form.model) + def add_new_plan_feature(self): if not handle_unsaved_changes(): return @@ -180,11 +197,10 @@ def _plan_geom_digitized(self, feature: QgsFeature): if not plan_layer: return - attribute_form = PlanAttributeForm(self.regulation_group_libraries) + plan_model = Plan(geom=feature.geometry()) + attribute_form = PlanAttributeForm(plan_model, self.regulation_group_libraries) if attribute_form.exec_(): - plan_attributes = attribute_form.get_plan_attributes() - plan_attributes.geom = feature.geometry() - feature = save_plan(plan_attributes) + feature = save_plan(attribute_form.model) plan_to_be_activated = feature["id"] else: plan_to_be_activated = self.previous_active_plan_id @@ -379,33 +395,37 @@ def _delete_feature(feature: QgsFeature, layer: QgsVectorLayer, delete_text: str layer.commitChanges(stopEditing=False) -def save_plan(plan_data: Plan) -> QgsFeature: - plan_layer = PlanLayer.get_from_project() - in_edit_mode = plan_layer.isEditable() - if not in_edit_mode: - plan_layer.startEditing() - - edit_message = "Kaavan lisäys" if plan_data.id_ is None else "Kaavan muokkaus" - plan_layer.beginEditCommand(edit_message) - - # plan_data.organisation_id = "99e20d66-9730-4110-815f-5947d3f8abd3" - plan_feature = PlanLayer.feature_from_model(plan_data) +def save_plan(plan: Plan) -> QgsFeature: + feature = PlanLayer.feature_from_model(plan) + layer = PlanLayer.get_from_project() - if plan_data.id_ is None: - plan_layer.addFeature(plan_feature) - else: - plan_layer.updateFeature(plan_feature) + editing = plan.id_ is not None + _save_feature( + feature=feature, + layer=layer, + id_=plan.id_, + edit_text="Kaavan muokkaus" if editing else "Kaavan luominen", + ) - plan_layer.endEditCommand() - plan_layer.commitChanges(stopEditing=False) + # Check for deleted general regulations + if editing: + for association in RegulationGroupAssociationLayer.get_dangling_associations( + plan.general_regulations, feature["id"], PlanLayer.name + ): + _delete_feature( + association, + RegulationGroupAssociationLayer.get_from_project(), + "Kaavamääräysryhmän assosiaation poisto", + ) - if plan_data.general_regulations: - for regulation_group in plan_data.general_regulations: - plan_id = plan_feature["id"] + # Save general regulations + if plan.general_regulations: + for regulation_group in plan.general_regulations: + plan_id = feature["id"] regulation_group_feature = save_regulation_group(regulation_group, plan_id) save_regulation_group_association(regulation_group_feature["id"], PlanLayer.name, plan_id) - return plan_feature + return feature def save_plan_feature(plan_feature: PlanFeature, plan_id: str | None = None) -> QgsFeature: diff --git a/arho_feature_template/gui/components/code_combobox.py b/arho_feature_template/gui/components/code_combobox.py index 2dda623..d5d56f6 100644 --- a/arho_feature_template/gui/components/code_combobox.py +++ b/arho_feature_template/gui/components/code_combobox.py @@ -39,6 +39,17 @@ def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None: def value(self) -> str: return self.currentData() + def set_value(self, value: str | None) -> None: + if value is None: + self.setCurrentIndex(0) + return + + index = self.findData(value) + if index != -1: + self.setCurrentIndex(index) + else: + self.setCurrentIndex(0) # Set NULL if not found + class HierarchicalCodeComboBox(QComboBox): def __init__(self, parent=None): @@ -56,8 +67,8 @@ def __init__(self, parent=None): null_item = QTreeWidgetItem(["NULL"]) null_item.setData(0, Qt.UserRole, None) self.tree_widget.addTopLevelItem(null_item) - null_index = self.tree_widget.indexFromItem(null_item) - self.tree_widget.setCurrentIndex(null_index) + self.null_index = self.tree_widget.indexFromItem(null_item) + self.tree_widget.setCurrentIndex(self.null_index) def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None: try: @@ -90,3 +101,30 @@ def populate_from_code_layer(self, layer_type: type[AbstractCodeLayer]) -> None: def value(self) -> str: item = self.tree_widget.selectedItems()[0] return item.data(0, Qt.UserRole) + + def set_value(self, value: str | None) -> None: + # NOTE: Does not work fully currently + + def find_item_recursive(item: QTreeWidgetItem, value: str) -> QTreeWidgetItem: + if item.data(0, Qt.UserRole) == value: + return item + for i in range(item.childCount()): + found_item = find_item_recursive(item.child(i), value) + if found_item: + return found_item + return None + + if value is None: + self.setCurrentIndex(0) + return + + for i in range(self.count()): + found_item = find_item_recursive(self.tree_widget.topLevelItem(i), value) + if found_item: + idx = self.tree_widget.indexFromItem(found_item) + self.setRootModelIndex(idx.parent()) + self.setCurrentIndex(idx.row()) + self.setRootModelIndex(self.null_index.parent()) + return + + self.setCurrentIndex(0) # Set combobox index to NULL diff --git a/arho_feature_template/gui/dialogs/plan_attribute_form.py b/arho_feature_template/gui/dialogs/plan_attribute_form.py index 3f54f60..e5dc281 100644 --- a/arho_feature_template/gui/dialogs/plan_attribute_form.py +++ b/arho_feature_template/gui/dialogs/plan_attribute_form.py @@ -51,15 +51,33 @@ class PlanAttributeForm(QDialog, FormClass): # type: ignore button_box: QDialogButtonBox - def __init__(self, regulation_group_libraries: list[RegulationGroupLibrary], parent=None): + def __init__(self, plan: Plan, regulation_group_libraries: list[RegulationGroupLibrary], parent=None): super().__init__(parent) self.setupUi(self) + self.plan = plan + self.plan_type_combo_box.populate_from_code_layer(PlanTypeLayer) self.lifecycle_status_combo_box.populate_from_code_layer(LifeCycleStatusLayer) self.organisation_combo_box.populate_from_code_layer(OrganisationLayer) + self.plan_type_combo_box.set_value(plan.plan_type_id) + self.lifecycle_status_combo_box.set_value(plan.lifecycle_status_id) + self.organisation_combo_box.set_value(plan.organisation_id) + self.name_line_edit.setText(plan.name if plan.name else "") + self.description_text_edit.setText(plan.description if plan.description else "") + self.permanent_identifier_line_edit.setText( + plan.permanent_plan_identifier if plan.permanent_plan_identifier else "" + ) + self.record_number_line_edit.setText(plan.record_number if plan.record_number else "") + self.producers_plan_identifier_line_edit.setText( + plan.producers_plan_identifier if plan.producers_plan_identifier else "" + ) + self.matter_management_identifier_line_edit.setText( + plan.matter_management_identifier if plan.matter_management_identifier else "" + ) + self.name_line_edit.textChanged.connect(self._check_required_fields) self.organisation_combo_box.currentIndexChanged.connect(self._check_required_fields) self.plan_type_combo_box.currentIndexChanged.connect(self._check_required_fields) @@ -74,7 +92,13 @@ def __init__(self, regulation_group_libraries: list[RegulationGroupLibrary], par self.regulation_groups_selection_widget.tree.itemDoubleClicked.connect(self.add_selected_plan_regulation_group) + for regulation_group in plan.general_regulations: + self.add_plan_regulation_group(regulation_group) + self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) + self.button_box.accepted.connect(self._on_ok_clicked) + + self._check_required_fields() def _check_required_fields(self) -> None: ok_button = self.button_box.button(QDialogButtonBox.Ok) @@ -129,9 +153,9 @@ def init_plan_regulation_group_library(self, library: RegulationGroupLibrary): # --- - def get_plan_attributes(self) -> Plan: + def into_model(self) -> Plan: return Plan( - id_=None, + id_=self.plan.id_, name=self.name_line_edit.text(), description=self.description_text_edit.toPlainText() or None, plan_type_id=self.plan_type_combo_box.value(), @@ -142,4 +166,9 @@ def get_plan_attributes(self) -> Plan: matter_management_identifier=self.matter_management_identifier_line_edit.text() or None, lifecycle_status_id=self.lifecycle_status_combo_box.value(), general_regulations=[reg_group_widget.into_model() for reg_group_widget in self.regulation_group_widgets], + geom=self.plan.geom, ) + + def _on_ok_clicked(self): + self.model = self.into_model() + self.accept() diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index 0d0ef8e..1291d88 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -167,6 +167,16 @@ def initGui(self) -> None: # noqa N802 status_tip="Lataa/avaa kaava", ) + self.edit_land_use_plan_action = self.add_action( + text="Muokkaa kaavaa", + # icon=QgsApplication.getThemeIcon("mActionFileOpen.svg"), + triggered_callback=self.plan_manager.edit_plan, + parent=iface.mainWindow(), + add_to_menu=True, + add_to_toolbar=True, + status_tip="Muokkaa aktiivisen kaavan tietoja", + ) + self.new_feature_dock_action = self.add_action( text="Luo kaavakohde", icon=QgsApplication.getThemeIcon("mIconFieldGeometry.svg"), diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index e374be3..40c878a 100644 --- a/arho_feature_template/project/layers/plan_layers.py +++ b/arho_feature_template/project/layers/plan_layers.py @@ -5,21 +5,18 @@ from numbers import Number from string import Template from textwrap import dedent -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar from qgis.core import NULL, QgsExpressionContextUtils, QgsFeature, QgsProject, QgsVectorLayerUtils from qgis.utils import iface -from arho_feature_template.core.models import PlanFeature, Regulation, RegulationGroup, RegulationLibrary +from arho_feature_template.core.models import Plan, PlanFeature, Regulation, RegulationGroup, RegulationLibrary from arho_feature_template.exceptions import FeatureNotFoundError, LayerEditableError from arho_feature_template.project.layers import AbstractLayer from arho_feature_template.project.layers.code_layers import PlanRegulationTypeLayer logger = logging.getLogger(__name__) -if TYPE_CHECKING: - from arho_feature_template.core.models import Plan - class AbstractPlanLayer(AbstractLayer): filter_template: ClassVar[Template] @@ -65,13 +62,12 @@ class PlanLayer(AbstractPlanLayer): @classmethod def feature_from_model(cls, model: Plan) -> QgsFeature: - layer = cls.get_from_project() + feature = cls.initialize_feature_from_model(model) if not model.geom: message = "Plan must have a geometry to be added to the layer" raise ValueError(message) - feature = QgsVectorLayerUtils.createFeature(layer, model.geom) feature["name"] = {"fin": model.name} feature["description"] = {"fin": model.description} feature["permanent_plan_identifier"] = model.permanent_plan_identifier @@ -84,6 +80,26 @@ def feature_from_model(cls, model: Plan) -> QgsFeature: return feature + @classmethod + def model_from_feature(cls, feature: QgsFeature) -> Plan: + return Plan( + geom=feature.geometry(), + name=feature["name"]["fin"], + description=feature["description"]["fin"], + permanent_plan_identifier=feature["permanent_plan_identifier"], + record_number=feature["record_number"], + producers_plan_identifier=feature["producers_plan_identifier"], + matter_management_identifier=feature["matter_management_identifier"], + plan_type_id=feature["plan_type_id"], + lifecycle_status_id=feature["lifecycle_status_id"], + organisation_id=feature["organisation_id"], + general_regulations=[ + RegulationGroupLayer.model_from_feature(RegulationGroupLayer.get_feature_by_id(group_id)) + for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + ], + id_=feature["id"], + ) + class PlanFeatureLayer(AbstractPlanLayer): @classmethod From 60aba95598bba3996a22236483fa40d9fa26e205 Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Tue, 21 Jan 2025 13:10:36 +0200 Subject: [PATCH 5/6] optimize feature requests --- arho_feature_template/core/plan_manager.py | 2 +- arho_feature_template/exceptions.py | 5 ++ .../project/layers/__init__.py | 41 ++++++++++++++-- .../project/layers/plan_layers.py | 47 ++++++++++++------- 4 files changed, 73 insertions(+), 22 deletions(-) diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py index 1527ae9..0766c16 100644 --- a/arho_feature_template/core/plan_manager.py +++ b/arho_feature_template/core/plan_manager.py @@ -161,7 +161,7 @@ def edit_plan(self): return active_plan_id = QgsExpressionContextUtils.projectScope(QgsProject.instance()).variable("active_plan_id") - feature = PlanLayer.get_feature_by_id(active_plan_id) + feature = PlanLayer.get_feature_by_id(active_plan_id, no_geometries=False) if feature is None: iface.messageBar().pushWarning("", "No active/open plan found!") return diff --git a/arho_feature_template/exceptions.py b/arho_feature_template/exceptions.py index 4e82fb5..7b08566 100644 --- a/arho_feature_template/exceptions.py +++ b/arho_feature_template/exceptions.py @@ -21,6 +21,11 @@ def __init__(self, layer_name: str): super().__init__(f"Layer {layer_name} is not a vector layer") +class LayerNameNotFoundError(Exception): + def __init__(self, layer_name: str): + super().__init__(f"Layer {layer_name} not found") + + class ConfigSyntaxError(Exception): def __init__(self, message: str): super().__init__(f"Invalid config syntax: {message}") diff --git a/arho_feature_template/project/layers/__init__.py b/arho_feature_template/project/layers/__init__.py index 835ef3a..af2efd4 100644 --- a/arho_feature_template/project/layers/__init__.py +++ b/arho_feature_template/project/layers/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from abc import ABC -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Generator from qgis.core import QgsFeatureRequest @@ -23,6 +23,39 @@ def get_features(cls) -> QgsFeatureIterator: return cls.get_from_project().getFeatures() @classmethod - def get_feature_by_id(cls, _id: str) -> QgsFeature | None: - request = QgsFeatureRequest().setFilterExpression(f"\"id\"='{_id}'") - return next(cls.get_from_project().getFeatures(request), None) + def get_features_by_attribute_value( + cls, + attribute: str, + value: str, + no_geometries: bool = True, # noqa: FBT001, FBT002 + ) -> Generator[QgsFeature]: + layer = cls.get_from_project() + request = QgsFeatureRequest().setFilterExpression(f"\"{attribute}\"='{value}'") + if no_geometries: + request.setFlags(QgsFeatureRequest.NoGeometry) + yield from layer.getFeatures(request) + + @classmethod + def get_feature_by_attribute_value( + cls, + attribute: str, + value: str, + no_geometries: bool = True, # noqa: FBT001, FBT002 + ) -> QgsFeature | None: + gen = cls.get_features_by_attribute_value(attribute, value, no_geometries) + return next(gen, None) + + @classmethod + def get_feature_by_id(cls, id_: str, no_geometries: bool = True) -> QgsFeature | None: # noqa: FBT001, FBT002 + return cls.get_feature_by_attribute_value("id", id_, no_geometries) + + @classmethod + def get_attribute_values_by_another_attribute_value( + cls, target_attribute: str, filter_attribute: str, filter_value: str + ) -> Generator[Any]: + layer = cls.get_from_project() + request = QgsFeatureRequest().setFilterExpression(f"\"{filter_attribute}\"='{filter_value}'") + request.setSubsetOfAttributes([target_attribute], layer.fields()) + request.setFlags(QgsFeatureRequest.NoGeometry) + for feature in layer.getFeatures(request): + yield feature[target_attribute] diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index 40c878a..609b244 100644 --- a/arho_feature_template/project/layers/plan_layers.py +++ b/arho_feature_template/project/layers/plan_layers.py @@ -5,13 +5,13 @@ from numbers import Number from string import Template from textwrap import dedent -from typing import Any, ClassVar +from typing import Any, ClassVar, Generator from qgis.core import NULL, QgsExpressionContextUtils, QgsFeature, QgsProject, QgsVectorLayerUtils from qgis.utils import iface from arho_feature_template.core.models import Plan, PlanFeature, Regulation, RegulationGroup, RegulationLibrary -from arho_feature_template.exceptions import FeatureNotFoundError, LayerEditableError +from arho_feature_template.exceptions import FeatureNotFoundError, LayerEditableError, LayerNotFoundError from arho_feature_template.project.layers import AbstractLayer from arho_feature_template.project.layers.code_layers import PlanRegulationTypeLayer @@ -48,7 +48,7 @@ def feature_from_model(cls, model: Any) -> QgsFeature: @classmethod def initialize_feature_from_model(cls, model: Any) -> QgsFeature: if model.id_ is not None: # Expects all plan layer models to have 'id_' attribute - feature = cls.get_feature_by_id(model.id_) + feature = cls.get_feature_by_id(model.id_, no_geometries=False) if not feature: raise FeatureNotFoundError(model.id_, cls.name) else: @@ -82,6 +82,10 @@ def feature_from_model(cls, model: Plan) -> QgsFeature: @classmethod def model_from_feature(cls, feature: QgsFeature) -> Plan: + general_regulation_features = [ + RegulationGroupLayer.get_feature_by_id(group_id) + for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + ] return Plan( geom=feature.geometry(), name=feature["name"]["fin"], @@ -94,8 +98,9 @@ def model_from_feature(cls, feature: QgsFeature) -> Plan: lifecycle_status_id=feature["lifecycle_status_id"], organisation_id=feature["organisation_id"], general_regulations=[ - RegulationGroupLayer.model_from_feature(RegulationGroupLayer.get_feature_by_id(group_id)) - for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + RegulationGroupLayer.model_from_feature(feat) + for feat in general_regulation_features + if feat is not None ], id_=feature["id"], ) @@ -123,6 +128,10 @@ def feature_from_model(cls, model: PlanFeature, plan_id: str | None = None) -> Q @classmethod def model_from_feature(cls, feature: QgsFeature) -> PlanFeature: + regulation_group_features = [ + RegulationGroupLayer.get_feature_by_id(group_id) + for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + ] return PlanFeature( geom=feature.geometry(), type_of_underground_id=feature["type_of_underground_id"], @@ -130,8 +139,7 @@ def model_from_feature(cls, feature: QgsFeature) -> PlanFeature: name=feature["name"]["fin"], description=feature["description"]["fin"], regulation_groups=[ - RegulationGroupLayer.model_from_feature(RegulationGroupLayer.get_feature_by_id(group_id)) - for group_id in RegulationGroupAssociationLayer.get_group_ids_for_feature(feature["id"], cls.name) + RegulationGroupLayer.model_from_feature(feat) for feat in regulation_group_features if feat is not None ], plan_id=feature["plan_id"], id_=feature["id"], @@ -228,9 +236,12 @@ def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str attribute = cls.layer_name_to_attribute_map.get(layer_name) # Check if association exists to avoid duplicate assocations - for feature in layer.getFeatures(): - if feature["plan_regulation_group_id"] == regulation_group_id and feature[attribute] == feature_id: + for feature in cls.get_features_by_attribute_value("plan_regulation_group_id", regulation_group_id): + if feature[attribute] == feature_id: return None + # for feature in layer.getFeatures(): + # if feature["plan_regulation_group_id"] == regulation_group_id and feature[attribute] == feature_id: + # return None feature = QgsVectorLayerUtils.createFeature(layer) feature["plan_regulation_group_id"] = regulation_group_id @@ -243,16 +254,18 @@ def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str return feature @classmethod - def get_associations_for_feature(cls, feature_id: str, layer_name: str) -> list[QgsFeature]: + def get_associations_for_feature(cls, feature_id: str, layer_name: str) -> Generator[QgsFeature]: attribute = cls.layer_name_to_attribute_map.get(layer_name) - return [feature for feature in cls.get_features() if feature[attribute] == feature_id] + if not attribute: + raise LayerNotFoundError(layer_name) + return cls.get_features_by_attribute_value(attribute, feature_id) @classmethod - def get_group_ids_for_feature(cls, feature_id: str, layer_name: str) -> list[str]: + def get_group_ids_for_feature(cls, feature_id: str, layer_name: str) -> Generator[str]: attribute = cls.layer_name_to_attribute_map.get(layer_name) - return [ - feature["plan_regulation_group_id"] for feature in cls.get_features() if feature[attribute] == feature_id - ] + if not attribute: + raise LayerNotFoundError(layer_name) + return cls.get_attribute_values_by_another_attribute_value("plan_regulation_group_id", attribute, feature_id) @classmethod def get_dangling_associations( @@ -318,8 +331,8 @@ def model_from_feature(cls, feature: QgsFeature) -> Regulation: ) @classmethod - def regulations_with_group_id(cls, group_id: str) -> list[QgsFeature]: - return [feat for feat in cls.get_features() if feat["plan_regulation_group_id"] == group_id] + def regulations_with_group_id(cls, group_id: str) -> Generator[QgsFeature]: + return cls.get_features_by_attribute_value("plan_regulation_group_id", group_id) class PlanPropositionLayer(AbstractPlanLayer): From 45533a220bca50598ce94903d78ff2f8708727cb Mon Sep 17 00:00:00 2001 From: Niko Aarnio Date: Tue, 21 Jan 2025 15:32:35 +0200 Subject: [PATCH 6/6] refactor existing assocation check, add comments to HierarchicalCodeComboBox --- arho_feature_template/core/plan_manager.py | 4 +- .../gui/components/code_combobox.py | 38 ++++++++++++------- .../project/layers/plan_layers.py | 16 ++++---- 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py index 0766c16..51f0908 100644 --- a/arho_feature_template/core/plan_manager.py +++ b/arho_feature_template/core/plan_manager.py @@ -493,9 +493,9 @@ def save_regulation_group_as_config(regulation_group: RegulationGroup): def save_regulation_group_association(regulation_group_id: str, layer_name: str, feature_id: str): - feature = RegulationGroupAssociationLayer.feature_from(regulation_group_id, layer_name, feature_id) - if not feature: + if RegulationGroupAssociationLayer.association_exists(regulation_group_id, layer_name, feature_id): return + feature = RegulationGroupAssociationLayer.feature_from(regulation_group_id, layer_name, feature_id) layer = RegulationGroupAssociationLayer.get_from_project() _save_feature(feature=feature, layer=layer, id_=None, edit_text="Kaavamääräysryhmän assosiaation lisäys") diff --git a/arho_feature_template/gui/components/code_combobox.py b/arho_feature_template/gui/components/code_combobox.py index d5d56f6..05e6da4 100644 --- a/arho_feature_template/gui/components/code_combobox.py +++ b/arho_feature_template/gui/components/code_combobox.py @@ -48,7 +48,7 @@ def set_value(self, value: str | None) -> None: if index != -1: self.setCurrentIndex(index) else: - self.setCurrentIndex(0) # Set NULL if not found + self.setCurrentIndex(0) # Set selection to NULL if item with `value` was not found class HierarchicalCodeComboBox(QComboBox): @@ -102,29 +102,39 @@ def value(self) -> str: item = self.tree_widget.selectedItems()[0] return item.data(0, Qt.UserRole) - def set_value(self, value: str | None) -> None: - # NOTE: Does not work fully currently + def _find_item_recursive(self, item: QTreeWidgetItem, value: str) -> QTreeWidgetItem: + """Recursively try to find item with given value and return the item if found.""" + # Found item, return it + if item.data(0, Qt.UserRole) == value: + return item + + # Loop children + for i in range(item.childCount()): + found_item = self._find_item_recursive(item.child(i), value) + if found_item: + return found_item - def find_item_recursive(item: QTreeWidgetItem, value: str) -> QTreeWidgetItem: - if item.data(0, Qt.UserRole) == value: - return item - for i in range(item.childCount()): - found_item = find_item_recursive(item.child(i), value) - if found_item: - return found_item - return None + return None + def set_value(self, value: str | None) -> None: + # Set selection to NULL if `value` is None if value is None: - self.setCurrentIndex(0) + self.setCurrentIndex(self.null_index) return + # Loop top level tree items for i in range(self.count()): - found_item = find_item_recursive(self.tree_widget.topLevelItem(i), value) + # Handle child items recursively + found_item = self._find_item_recursive(self.tree_widget.topLevelItem(i), value) + + # If matching item was found, set it as selected. Because of the hybrid TreeWidget + ComboBox + # nature of the widget, value setting is unintuitive and tricky if found_item: + self.tree_widget.setCurrentItem(found_item) idx = self.tree_widget.indexFromItem(found_item) self.setRootModelIndex(idx.parent()) self.setCurrentIndex(idx.row()) self.setRootModelIndex(self.null_index.parent()) return - self.setCurrentIndex(0) # Set combobox index to NULL + self.setCurrentIndex(self.null_index) # Set selection to NULL if item with `value` was not found diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index 609b244..c2954c4 100644 --- a/arho_feature_template/project/layers/plan_layers.py +++ b/arho_feature_template/project/layers/plan_layers.py @@ -235,14 +235,6 @@ def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str layer = cls.get_from_project() attribute = cls.layer_name_to_attribute_map.get(layer_name) - # Check if association exists to avoid duplicate assocations - for feature in cls.get_features_by_attribute_value("plan_regulation_group_id", regulation_group_id): - if feature[attribute] == feature_id: - return None - # for feature in layer.getFeatures(): - # if feature["plan_regulation_group_id"] == regulation_group_id and feature[attribute] == feature_id: - # return None - feature = QgsVectorLayerUtils.createFeature(layer) feature["plan_regulation_group_id"] = regulation_group_id @@ -253,6 +245,14 @@ def feature_from(cls, regulation_group_id: str, layer_name: str, feature_id: str return feature + @classmethod + def association_exists(cls, regulation_group_id: str, layer_name: str, feature_id: str): + attribute = cls.layer_name_to_attribute_map.get(layer_name) + for feature in cls.get_features_by_attribute_value("plan_regulation_group_id", regulation_group_id): + if feature[attribute] == feature_id: + return True + return False + @classmethod def get_associations_for_feature(cls, feature_id: str, layer_name: str) -> Generator[QgsFeature]: attribute = cls.layer_name_to_attribute_map.get(layer_name)