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):