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 c82ccb3..51f0908 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, @@ -22,6 +23,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, @@ -49,16 +51,16 @@ 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__) 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 +97,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: @@ -140,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, no_geometries=False) + 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 @@ -166,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 @@ -200,6 +230,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,59 +384,86 @@ def _save_feature(feature: QgsFeature, layer: QgsVectorLayer, id_: int | None, e 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() +def _delete_feature(feature: QgsFeature, layer: QgsVectorLayer, delete_text: str = ""): + if not layer.isEditable(): + layer.startEditing() + layer.beginEditCommand(delete_text) - edit_message = "Kaavan lisäys" if plan_data.id_ is None else "Kaavan muokkaus" - plan_layer.beginEditCommand(edit_message) + layer.deleteFeature(feature.id()) - # plan_data.organisation_id = "99e20d66-9730-4110-815f-5947d3f8abd3" - plan_feature = PlanLayer.feature_from_model(plan_data) + layer.endEditCommand() + layer.commitChanges(stopEditing=False) - if plan_data.id_ is None: - plan_layer.addFeature(plan_feature) - else: - plan_layer.updateFeature(plan_feature) - plan_layer.endEditCommand() - plan_layer.commitChanges(stopEditing=False) +def save_plan(plan: Plan) -> QgsFeature: + feature = PlanLayer.feature_from_model(plan) + layer = PlanLayer.get_from_project() + + editing = plan.id_ is not None + _save_feature( + feature=feature, + layer=layer, + id_=plan.id_, + edit_text="Kaavan muokkaus" if editing else "Kaavan luominen", + ) + + # 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: - 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 +492,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): + 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") - return feature - def save_regulation(regulation: Regulation) -> QgsFeature: feature = PlanRegulationLayer.feature_from_model(regulation) 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/gui/components/code_combobox.py b/arho_feature_template/gui/components/code_combobox.py index 2dda623..05e6da4 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 selection to NULL if item with `value` was 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,40 @@ 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 _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 + + return None + + def set_value(self, value: str | None) -> None: + # Set selection to NULL if `value` is None + if value is None: + self.setCurrentIndex(self.null_index) + return + + # Loop top level tree items + for i in range(self.count()): + # 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(self.null_index) # Set selection to NULL if item with `value` was not found 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..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) @@ -106,7 +130,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) @@ -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/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/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..b9e8b2c --- /dev/null +++ b/arho_feature_template/gui/tools/inspect_plan_features_tool.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from qgis.core import QgsFeature, QgsFeatureRequest, QgsPointXY, QgsRectangle, QgsSpatialIndex, QgsVectorLayer +from qgis.gui import QgsMapCanvas, QgsMapMouseEvent, QgsMapTool, QgsRubberBand +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 + + +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 = {} + 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 + + 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..1291d88 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) @@ -166,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"), @@ -209,6 +220,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/__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/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 diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index 1354caa..c2954c4 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, Generator 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.exceptions import FeatureNotFoundError, LayerEditableError +from arho_feature_template.core.models import Plan, PlanFeature, Regulation, RegulationGroup, RegulationLibrary +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 logger = logging.getLogger(__name__) -if TYPE_CHECKING: - from arho_feature_template.core.models import Plan - class AbstractPlanLayer(AbstractLayer): filter_template: ClassVar[Template] @@ -51,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: @@ -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,17 +80,41 @@ def feature_from_model(cls, model: Plan) -> QgsFeature: return feature + @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"], + 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(feat) + for feat in general_regulation_features + if feat is not None + ], + id_=feature["id"], + ) + 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 +126,25 @@ def feature_from_model(cls, model: PlanFeature, plan_id: str | None = None) -> Q return feature + @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"], + layer_name=cls.get_from_project().name(), + name=feature["name"]["fin"], + description=feature["description"]["fin"], + regulation_groups=[ + RegulationGroupLayer.model_from_feature(feat) for feat in regulation_group_features if feat is not None + ], + plan_id=feature["plan_id"], + id_=feature["id"], + ) + class LandUsePointLayer(PlanFeatureLayer): name = "Maankäytön kohteet" @@ -192,13 +231,13 @@ 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) 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 +245,36 @@ 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) + 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) -> Generator[str]: + attribute = cls.layer_name_to_attribute_map.get(layer_name) + 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( + 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" @@ -262,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):