diff --git a/arho_feature_template/core/plan_manager.py b/arho_feature_template/core/plan_manager.py index 5711626..c40196c 100644 --- a/arho_feature_template/core/plan_manager.py +++ b/arho_feature_template/core/plan_manager.py @@ -22,6 +22,7 @@ RegulationGroupLibrary, ) from arho_feature_template.exceptions import UnsavedChangesError +from arho_feature_template.gui.dialogs.import_features_form import ImportFeaturesForm from arho_feature_template.gui.dialogs.lifecycle_editor import LifecycleEditor from arho_feature_template.gui.dialogs.load_plan_dialog import LoadPlanDialog from arho_feature_template.gui.dialogs.plan_attribute_form import PlanAttributeForm @@ -33,16 +34,11 @@ from arho_feature_template.gui.tools.inspect_plan_features_tool import InspectPlanFeatures from arho_feature_template.project.layers.code_layers import PlanRegulationGroupTypeLayer, code_layers from arho_feature_template.project.layers.plan_layers import ( + FEATURE_LAYER_NAME_TO_CLASS_MAP, AdditionalInformationLayer, DocumentLayer, - LandUseAreaLayer, - LandUsePointLayer, LegalEffectAssociationLayer, LifeCycleLayer, - LineLayer, - OtherAreaLayer, - OtherPointLayer, - PlanFeatureLayer, PlanLayer, PlanPropositionLayer, PlanRegulationLayer, @@ -72,14 +68,6 @@ logger = logging.getLogger(__name__) -FEATURE_LAYER_NAME_TO_CLASS_MAP: dict[str, type[PlanFeatureLayer]] = { - LandUsePointLayer.name: LandUsePointLayer, - OtherPointLayer.name: OtherPointLayer, - LineLayer.name: LineLayer, - OtherAreaLayer.name: OtherAreaLayer, - LandUseAreaLayer.name: LandUseAreaLayer, -} - class PlanDigitizeMapTool(QgsMapToolDigitizeFeature): ... @@ -158,6 +146,11 @@ def initialize_libraries(self): ] self.new_feature_dock.initialize_feature_template_libraries(self.feature_template_libraries) + def open_import_features_dialog(self): + import_features_form = ImportFeaturesForm(self.active_plan_regulation_group_library) + if import_features_form.exec_(): + pass + def update_active_plan_regulation_group_library(self): self.active_plan_regulation_group_library = regulation_group_library_from_active_plan() self.regulation_groups_dock.update_regulation_groups(self.active_plan_regulation_group_library) diff --git a/arho_feature_template/gui/dialogs/import_features_form.py b/arho_feature_template/gui/dialogs/import_features_form.py new file mode 100644 index 0000000..679b19e --- /dev/null +++ b/arho_feature_template/gui/dialogs/import_features_form.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from importlib import resources +from typing import TYPE_CHECKING + +from qgis.core import ( + QgsFeature, + QgsFeatureIterator, + QgsFeatureRequest, + QgsFieldProxyModel, + QgsVectorLayer, +) +from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QProgressBar + +from arho_feature_template.core.models import PlanFeature, RegulationGroupLibrary +from arho_feature_template.project.layers.code_layers import UndergroundTypeLayer, code_layers +from arho_feature_template.project.layers.plan_layers import ( + FEATURE_LAYER_NAME_TO_CLASS_MAP, + RegulationGroupAssociationLayer, + plan_feature_layers, + plan_layers, +) +from arho_feature_template.utils.misc_utils import iface, use_wait_cursor + +if TYPE_CHECKING: + from qgis.gui import QgsCheckableComboBox, QgsFieldComboBox, QgsFieldExpressionWidget, QgsMapLayerComboBox + + from arho_feature_template.gui.components.code_combobox import CodeComboBox + + +ui_path = resources.files(__package__) / "import_features_form.ui" +FormClass, _ = uic.loadUiType(ui_path) + + +class ImportFeaturesForm(QDialog, FormClass): + def __init__(self, active_plan_regulation_groups_library: RegulationGroupLibrary): + super().__init__() + self.setupUi(self) + + # TYPES + self.source_layer_selection: QgsMapLayerComboBox + self.filter_expression: QgsFieldExpressionWidget + self.selected_features_only: QCheckBox + + self.name_selection: QgsFieldComboBox + self.description_selection: QgsFieldComboBox + self.feature_type_of_underground_selection: CodeComboBox + self.regulation_groups_selection: QgsCheckableComboBox + + self.target_layer_selection: QgsMapLayerComboBox + + self.progress_bar: QProgressBar + self.process_button_box: QDialogButtonBox + + # INIT + self.process_button_box.button(QDialogButtonBox.Ok).setText("Import") + self.process_button_box.accepted.connect(self.import_features) + self.process_button_box.rejected.connect(self.reject) + + # Source layer initialization + # Exclude all project layers from valid source layers + # NOTE: Some project layers are not included in either `plan_layers` or `code_layers`? + self.source_layer_selection.setLayer(iface.activeLayer()) + excluded_layers = [layer.get_from_project() for layer in plan_layers + code_layers] + self.source_layer_selection.setExceptedLayerList(excluded_layers) + self.source_layer_selection.layerChanged.connect(self._on_layer_selections_changed) + + # Target layer initialization + # Set only plan feature layers as valid target layers + self.target_layer_selection.clear() + self.target_layer_selection.setAdditionalLayers(layer.get_from_project() for layer in plan_feature_layers) + self.target_layer_selection.setCurrentIndex(0) + self.target_layer_selection.layerChanged.connect(self._on_layer_selections_changed) + + # Name field initialization + self.name_selection.setAllowEmptyFieldName(True) + self.name_selection.setFilters(QgsFieldProxyModel.Filter.String) + self.name_selection.setField("") + + # Description field initialization + self.description_selection.setAllowEmptyFieldName(True) + self.description_selection.setFilters(QgsFieldProxyModel.Filter.String) + self.description_selection.setField("") + + # Underground type initialization + # Remove NULL from the selections and set Maanpäällinen as default + self.feature_type_of_underground_selection.populate_from_code_layer(UndergroundTypeLayer) + self.feature_type_of_underground_selection.remove_item_by_text("NULL") + self.feature_type_of_underground_selection.setCurrentIndex(1) # Set default to Maanpäällinen (index 1) + + # Regulation groups initialization + # Only regulation group already in DB are shown and they are not categorized right now + # NOTE: This means groups that are "Aluevaraus" groups can be given to "Osa-alue" for example + i = 0 + for category in active_plan_regulation_groups_library.regulation_group_categories: + for group in category.regulation_groups: + self.regulation_groups_selection.addItem(str(group)) + self.regulation_groups_selection.setItemData(i, group.id_) + i += 1 + + self._on_layer_selections_changed(self.source_layer_selection.currentLayer()) + + def _on_layer_selections_changed(self, _: QgsVectorLayer): + self.source_layer: QgsVectorLayer = self.source_layer_selection.currentLayer() + self.source_layer_name: str = self.source_layer.name() + self.target_layer: QgsVectorLayer = self.target_layer_selection.currentLayer() + self.target_layer_name: str = self.target_layer.name() + + self.filter_expression.setLayer(self.source_layer) + self.name_selection.setLayer(self.source_layer) + self.description_selection.setLayer(self.source_layer) + + if self.source_and_target_layer_types_match(): + self.process_button_box.button(QDialogButtonBox.Ok).setEnabled(True) + else: + self.process_button_box.button(QDialogButtonBox.Ok).setEnabled(False) + + def source_and_target_layer_types_match(self) -> bool: + if not self.source_layer or not self.target_layer: + return False + return self.source_layer.wkbType() is self.target_layer.wkbType() + + @use_wait_cursor + def import_features(self): + if not self.source_layer or not self.target_layer: + return + + self.progress_bar.setValue(0) + source_features = list(self.get_source_features(self.source_layer)) + if not source_features: + iface.messageBar().pushInfo("", "Yhtään kohdetta ei tuotu.") + return + + # Create and add new plan features + plan_features = self.create_plan_features(source_features) + total_count = len(plan_features) + failed_count = 0 + success_count = 0 + for i, feat in enumerate(plan_features): + self.progress_bar.setValue(int((i + 1) / total_count * 100)) + if self._save_feature(feat, self.target_layer, None, "Kaavakohteen lisääminen"): + success_count += 1 + else: + failed_count += 1 + + # If regulation groups are defined, associate them with the plan features + if len(self.regulation_groups_selection.checkedItems()) > 0: + associations = self.create_regulation_group_associations(plan_features) + associations_layer = RegulationGroupAssociationLayer.get_from_project() + for association in associations: + self._save_feature(association, associations_layer, None, "Kaavamääräysryhmän assosiaation lisääminen") + + if failed_count == 0: + iface.messageBar().pushSuccess("", "Kaavakohteet tuotiin onnistuneesti.") + else: + iface.messageBar().pushInfo("", f"Osa kaavakohteista tuotiin epäonnistuneesti ({failed_count}).") + + self.progress_bar.setValue(100) + + def get_source_features(self, source_layer: QgsVectorLayer) -> QgsFeatureIterator | list[QgsFeature]: + expression_text = self.filter_expression.currentText() + + # Case 1: Both selection and expression + if self.selected_features_only.isChecked() and expression_text: + selected_features = source_layer.selectedFeatures() + request = QgsFeatureRequest().setFilterExpression(expression_text) + source_features = [feat for feat in source_layer.getFeatures(request) if feat in selected_features] + + # Case 2: Only selection + elif self.selected_features_only.isChecked(): + source_features = source_layer.selectedFeatures() + + # Case 3: Only expression + elif expression_text: + request = QgsFeatureRequest().setFilterExpression(expression_text) + source_features = source_layer.getFeatures(request) + + # Case 4: No expression or selection + else: + source_features = source_layer.getFeatures() + + return source_features + + def create_plan_features(self, source_features: QgsFeatureIterator | list[QgsFeature]) -> list[QgsFeature]: + type_of_underground_id = self.feature_type_of_underground_selection.value() + source_layer_name_field = self.name_selection.currentField() + source_layer_description_field = self.description_selection.currentField() + layer_class = FEATURE_LAYER_NAME_TO_CLASS_MAP.get(self.target_layer_name) + if not layer_class: + msg = f"Could not find plan feature layer class for layer name {self.target_layer_name}" + raise ValueError(msg) + + return [ + layer_class.feature_from_model( + PlanFeature( + geom=feature.geometry(), + type_of_underground_id=type_of_underground_id, + layer_name=self.target_layer_name, + name=feature[source_layer_name_field] if source_layer_name_field else None, + description=feature[source_layer_description_field] if source_layer_description_field else None, + ) + ) + for feature in source_features + ] + + def create_regulation_group_associations(self, plan_features: list[QgsFeature]) -> list[QgsFeature]: + return [ + RegulationGroupAssociationLayer.feature_from( + regulation_group_id, self.target_layer_name, plan_feature["id"] + ) + for regulation_group_id in self.regulation_groups_selection.checkedItemsData() + for plan_feature in plan_features + ] + + @staticmethod + def _save_feature(feature: QgsFeature, layer: QgsVectorLayer, id_: str | None, edit_text: str = "") -> bool: + if not layer.isEditable(): + layer.startEditing() + layer.beginEditCommand(edit_text) + + if id_ is None: + layer.addFeature(feature) + else: + layer.updateFeature(feature) + + layer.endEditCommand() + return layer.commitChanges(stopEditing=False) diff --git a/arho_feature_template/gui/dialogs/import_features_form.ui b/arho_feature_template/gui/dialogs/import_features_form.ui new file mode 100644 index 0000000..fe185dc --- /dev/null +++ b/arho_feature_template/gui/dialogs/import_features_form.ui @@ -0,0 +1,205 @@ + + + Dialog + + + + 0 + 0 + 607 + 473 + + + + Tuo kaavakohteita + + + + + + Tasot + + + + + + <html><head/><body><p><span style=" color:#ff0000;">*</span> Tuontitaso:</p></body></html> + + + + + + + + + + <html><head/><body><p><span style=" color:#ff0000;">*</span> Kohdetaso:</p></body></html> + + + + + + + + + + + + + Tuotavien kohteiden valinta + + + + + + Valintalauseke + + + + + + + + + + Tuo vain valitut kohteet + + + + + + + + + + + + + + + + + Tuontitasolta kopioitavat tiedot + + + + + + Nimikenttä tuontitasolla + + + + + + + + + + Kuvauskenttä tuontitasolla + + + + + + + + + + + + + Kaikille kohteille tulevat tiedot + + + + + + Maanalaisuuden laji + + + + + + + + + + Kaavamääräysryhmät + + + + + + + + + + + + + Qt::Vertical + + + + 20 + 13 + + + + + + + + + + 0 + + + + + + + + 0 + 0 + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + QgsCheckableComboBox + QComboBox +
qgscheckablecombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsFieldExpressionWidget + QWidget +
qgsfieldexpressionwidget.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + CodeComboBox + QComboBox +
arho_feature_template.gui.components.code_combobox
+
+
+ + +
diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py index ae45d1d..cc86a4f 100644 --- a/arho_feature_template/plugin.py +++ b/arho_feature_template/plugin.py @@ -259,6 +259,15 @@ def initGui(self) -> None: # noqa N802 status_tip="Tallenna aktiivinen kaava geotiff muodossa", ) + self.import_features_action = self.add_action( + text="Tuo kaavakohteita", + icon=QgsApplication.getThemeIcon("mActionSharingImport.svg"), + triggered_callback=self.plan_manager.open_import_features_dialog, + add_to_menu=True, + add_to_toolbar=True, + status_tip="Tuo kaavakohteita tietokantaan toisilta vektoritasoilta", + ) + self.plugin_settings_action = self.add_action( text="Asetukset", triggered_callback=self.open_settings, diff --git a/arho_feature_template/project/layers/plan_layers.py b/arho_feature_template/project/layers/plan_layers.py index 77c9f7a..bd1b0ea 100644 --- a/arho_feature_template/project/layers/plan_layers.py +++ b/arho_feature_template/project/layers/plan_layers.py @@ -721,6 +721,14 @@ def get_features_by_plan_id(cls, plan_id: str) -> list[QgsFeature]: return list(cls.get_features_by_attribute_value("plan_id", plan_id)) +FEATURE_LAYER_NAME_TO_CLASS_MAP: dict[str, type[PlanFeatureLayer]] = { + LandUsePointLayer.name: LandUsePointLayer, + OtherPointLayer.name: OtherPointLayer, + LineLayer.name: LineLayer, + OtherAreaLayer.name: OtherAreaLayer, + LandUseAreaLayer.name: LandUseAreaLayer, +} + plan_layers = AbstractPlanLayer.__subclasses__() plan_layers.remove(PlanFeatureLayer)