diff --git a/README.md b/README.md
index 6224972..75c0077 100644
--- a/README.md
+++ b/README.md
@@ -61,3 +61,7 @@ the one with the path `.venv\Scripts\python.exe`.
This plugin is distributed under the terms of the [GNU General Public License, version 2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html) license.
See [LICENSE](LICENSE) for more information.
+
+### Attributations
+Open icons created by Smashicons - Flaticon
+Land use icons created by Fusion5085 - Flaticon
diff --git a/arho_feature_template/core/exceptions.py b/arho_feature_template/core/exceptions.py
new file mode 100644
index 0000000..50b61b5
--- /dev/null
+++ b/arho_feature_template/core/exceptions.py
@@ -0,0 +1,2 @@
+class UnexpectedNoneError(Exception):
+ """Internal QGIS errors that should not be happened"""
diff --git a/arho_feature_template/core/new_plan.py b/arho_feature_template/core/new_plan.py
new file mode 100644
index 0000000..97dd5ee
--- /dev/null
+++ b/arho_feature_template/core/new_plan.py
@@ -0,0 +1,63 @@
+from qgis.core import QgsProject, QgsVectorLayer
+from qgis.utils import iface
+
+from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan
+
+
+class NewPlan:
+ def add_new_plan(self):
+ # Filtered layers are not editable, so clear filters first.
+ self.clear_all_filters()
+
+ layers = QgsProject.instance().mapLayersByName("Kaava")
+ if not layers:
+ iface.messageBar().pushMessage("Error", "Layer 'Kaava' not found", level=3)
+ return
+
+ kaava_layer = layers[0]
+
+ if not kaava_layer.isEditable():
+ kaava_layer.startEditing()
+
+ iface.setActiveLayer(kaava_layer)
+
+ iface.actionAddFeature().trigger()
+
+ # Connect the featureAdded signal
+ kaava_layer.featureAdded.connect(self.feature_added)
+
+ def feature_added(self):
+ kaava_layer = iface.activeLayer()
+ kaava_layer.featureAdded.disconnect()
+ feature_ids_before_commit = kaava_layer.allFeatureIds()
+ if kaava_layer.isEditable():
+ if not kaava_layer.commitChanges():
+ iface.messageBar().pushMessage("Error", "Failed to commit changes to the layer.", level=3)
+ return
+ else:
+ iface.messageBar().pushMessage("Error", "Layer is not editable.", level=3)
+ return
+
+ feature_ids_after_commit = kaava_layer.allFeatureIds()
+
+ # Find the new plan.id by comparing fids before and after commit.
+ new_feature_id = next((fid for fid in feature_ids_after_commit if fid not in feature_ids_before_commit), None)
+
+ if new_feature_id is not None:
+ new_feature = kaava_layer.getFeature(new_feature_id)
+
+ if new_feature.isValid():
+ feature_id_value = new_feature["id"]
+ update_selected_plan(LandUsePlan(feature_id_value))
+ else:
+ iface.messageBar().pushMessage("Error", "Invalid feature retrieved.", level=3)
+ else:
+ iface.messageBar().pushMessage("Error", "No new feature was added.", level=3)
+
+ def clear_all_filters(self):
+ """Clear filters for all vector layers in the project."""
+ layers = QgsProject.instance().mapLayers().values()
+
+ for layer in layers:
+ if isinstance(layer, QgsVectorLayer):
+ layer.setSubsetString("")
diff --git a/arho_feature_template/core/update_plan.py b/arho_feature_template/core/update_plan.py
new file mode 100644
index 0000000..d083090
--- /dev/null
+++ b/arho_feature_template/core/update_plan.py
@@ -0,0 +1,62 @@
+from dataclasses import dataclass
+
+from qgis.core import QgsMapLayer, QgsProject, QgsVectorLayer
+from qgis.utils import iface
+
+
+# To be extended and moved
+@dataclass
+class LandUsePlan:
+ id: str
+
+
+# To be replaced later
+LAYER_PLAN_ID_MAP = {
+ "Kaava": "id",
+ "Maankäytön kohteet": "plan_id",
+ "Muut pisteet": "plan_id",
+ "Viivat": "plan_id",
+ "Aluevaraus": "plan_id",
+ "Osa-alue": "plan_id",
+}
+
+
+def update_selected_plan(new_plan: LandUsePlan):
+ """Update the project layers based on the selected land use plan."""
+ plan_id = new_plan.id
+
+ for layer_name, field_name in LAYER_PLAN_ID_MAP.items():
+ # Set the filter on each layer using the plan_id
+ set_filter_for_vector_layer(layer_name, field_name, plan_id)
+
+
+def set_filter_for_vector_layer(layer_name: str, field_name: str, field_value: str):
+ """Set a filter for the given vector layer."""
+ layers = QgsProject.instance().mapLayersByName(layer_name)
+
+ if not _check_layer_count(layers):
+ return
+
+ layer = layers[0]
+
+ expression = f"\"{field_name}\" = '{field_value}'"
+
+ # Apply the filter to the layer
+ if not layer.setSubsetString(expression):
+ iface.messageBar().pushMessage("Error", f"Failed to filter layer {layer_name} with query {expression}", level=3)
+
+
+def _check_layer_count(layers: list) -> bool:
+ """Check if any layers are returned."""
+ if not layers:
+ iface.messageBar().pushMessage("Error", "ERROR: No layers found with the specified name.", level=3)
+ return False
+ return True
+
+
+def _check_vector_layer(layer: QgsMapLayer) -> bool:
+ """Check if the given layer is a vector layer."""
+ if not isinstance(layer, QgsVectorLayer):
+ iface.messageBar().pushMessage("Error", f"Layer {layer.name()} is not a vector layer: {type(layer)}", level=3)
+ return False
+ return True
diff --git a/arho_feature_template/gui/load_plan_dialog.py b/arho_feature_template/gui/load_plan_dialog.py
new file mode 100644
index 0000000..8cbf984
--- /dev/null
+++ b/arho_feature_template/gui/load_plan_dialog.py
@@ -0,0 +1,145 @@
+from importlib import resources
+
+from qgis.core import QgsProviderRegistry
+from qgis.PyQt import uic
+from qgis.PyQt.QtCore import QRegularExpression, QSortFilterProxyModel, Qt
+from qgis.PyQt.QtGui import QStandardItem, QStandardItemModel
+from qgis.PyQt.QtWidgets import QComboBox, QDialog, QDialogButtonBox, QLineEdit, QMessageBox, QPushButton, QTableView
+
+from arho_feature_template.core.exceptions import UnexpectedNoneError
+
+ui_path = resources.files(__package__) / "load_plan_dialog.ui"
+
+LoadPlanDialogBase, _ = uic.loadUiType(ui_path)
+
+
+class PlanFilterProxyModel(QSortFilterProxyModel):
+ def filterAcceptsRow(self, source_row, source_parent): # noqa: N802
+ model = self.sourceModel()
+ if not model:
+ return False
+
+ filter_text = self.filterRegularExpression().pattern()
+ if not filter_text:
+ return True
+
+ for column in range(5):
+ index = model.index(source_row, column, source_parent)
+ data = model.data(index)
+ if data and filter_text.lower() in data.lower():
+ return True
+
+ return False
+
+
+class LoadPlanDialog(QDialog, LoadPlanDialogBase): # type: ignore
+ connectionComboBox: QComboBox # noqa: N815
+ push_button_load: QPushButton
+ planTableView: QTableView # noqa: N815
+ searchLineEdit: QLineEdit # noqa: N815
+ buttonBox: QDialogButtonBox # noqa: N815
+
+ def __init__(self, parent, connections):
+ super().__init__(parent)
+ self.setupUi(self)
+
+ self._selected_plan_id = None
+
+ self.buttonBox.rejected.connect(self.reject)
+ self.buttonBox.accepted.connect(self.accept)
+ self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)
+
+ self.push_button_load.clicked.connect(self.load_plans)
+ self.searchLineEdit.textChanged.connect(self.filter_plans)
+
+ self.connectionComboBox.addItems(connections)
+
+ self.planTableView.setSelectionMode(QTableView.SingleSelection)
+ self.planTableView.setSelectionBehavior(QTableView.SelectRows)
+ self.planTableView.selectionModel().selectionChanged.connect(self.on_selection_changed)
+
+ self.model = QStandardItemModel()
+ self.model.setColumnCount(5)
+ self.model.setHorizontalHeaderLabels(
+ [
+ "ID",
+ "Tuottajan kaavatunnus",
+ "Nimi",
+ "Kaavan elinkaaren tila",
+ "Kaavalaji",
+ ]
+ )
+
+ self.filterProxyModel = PlanFilterProxyModel()
+ self.filterProxyModel.setSourceModel(self.model)
+ self.filterProxyModel.setFilterCaseSensitivity(Qt.CaseInsensitive)
+
+ self.planTableView.setModel(self.filterProxyModel)
+
+ def load_plans(self):
+ self.model.removeRows(0, self.model.rowCount())
+
+ selected_connection = self.connectionComboBox.currentText()
+ if not selected_connection:
+ self.planTableView.setModel(QStandardItemModel())
+ return
+
+ provider_registry = QgsProviderRegistry.instance()
+ if provider_registry is None:
+ raise UnexpectedNoneError
+ postgres_provider_metadata = provider_registry.providerMetadata("postgres")
+ if postgres_provider_metadata is None:
+ raise UnexpectedNoneError
+
+ try:
+ connection = postgres_provider_metadata.createConnection(selected_connection)
+ plans = connection.executeSql("""
+ SELECT
+ p.id,
+ p.producers_plan_identifier,
+ p.name ->> 'fin' AS name_fin,
+ l.name ->> 'fin' AS lifecycle_status_fin,
+ pt.name ->> 'fin' AS plan_type_fin
+ FROM
+ hame.plan p
+ LEFT JOIN
+ codes.lifecycle_status l
+ ON
+ p.lifecycle_status_id = l.id
+ LEFT JOIN
+ codes.plan_type pt
+ ON
+ p.plan_type_id = pt.id;
+ """)
+ for plan in plans:
+ self.model.appendRow([QStandardItem(column) for column in plan])
+
+ except Exception as e: # noqa: BLE001
+ QMessageBox.critical(self, "Error", f"Failed to load plans: {e}")
+ self.model.removeRows(0, self.model.rowCount())
+
+ def filter_plans(self):
+ search_text = self.searchLineEdit.text()
+ if search_text:
+ search_regex = QRegularExpression(search_text)
+ self.filterProxyModel.setFilterRegularExpression(search_regex)
+ else:
+ self.filterProxyModel.setFilterRegularExpression("")
+
+ def on_selection_changed(self):
+ # Enable the OK button only if a row is selected
+ selection = self.planTableView.selectionModel().selectedRows()
+ ok_button = self.buttonBox.button(QDialogButtonBox.Ok)
+ if selection:
+ selected_row = selection[0].row()
+ self._selected_plan_id = self.planTableView.model().index(selected_row, 0).data()
+ ok_button.setEnabled(True)
+ else:
+ self._selected_plan_id = None
+ ok_button.setEnabled(False)
+
+ def get_selected_connection(self):
+ return self.connectionComboBox.currentText()
+
+ def get_selected_plan_id(self):
+ return self._selected_plan_id
diff --git a/arho_feature_template/gui/load_plan_dialog.ui b/arho_feature_template/gui/load_plan_dialog.ui
new file mode 100644
index 0000000..0a0c82d
--- /dev/null
+++ b/arho_feature_template/gui/load_plan_dialog.ui
@@ -0,0 +1,147 @@
+
+
+ LoadPlanDialog
+
+
+
+ 0
+ 0
+ 800
+ 600
+
+
+
+
+ 800
+ 600
+
+
+
+ Avaa kaava
+
+
+ -
+
+
+ Valitse tietokantayhteys:
+
+
+
+ -
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ Lataa kaavat
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::Expanding
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ Etsi kaavoja:
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Etsi kaavoja...
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::Expanding
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ Kaavat:
+
+
+
+ -
+
+
+
+ 1
+ 1
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::Expanding
+
+
+
+ 0
+ 0
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
diff --git a/arho_feature_template/gui/plan_regulation_group_widget.py b/arho_feature_template/gui/plan_regulation_group_widget.py
index 96e5784..916bcbb 100644
--- a/arho_feature_template/gui/plan_regulation_group_widget.py
+++ b/arho_feature_template/gui/plan_regulation_group_widget.py
@@ -6,6 +6,7 @@
from qgis.core import QgsApplication
from qgis.PyQt import uic
+from qgis.PyQt.QtCore import pyqtSignal
from qgis.PyQt.QtGui import QFont, QIcon
from qgis.PyQt.QtWidgets import QLabel, QLineEdit, QWidget
@@ -24,6 +25,8 @@
class PlanRegulationGroupWidget(QWidget, FormClass): # type: ignore
"""A widget representation of a plan regulation group."""
+ delete_signal = pyqtSignal(QWidget)
+
def __init__(self, feature: Feature):
super().__init__()
self.setupUi(self)
@@ -62,16 +65,20 @@ def __init__(self, feature: Feature):
if child.layer == "plan_requlation":
self.create_widgets_for_plan_regulation(child)
+ def request_delete(self):
+ self.delete_signal.emit(self)
+
def init_buttons(self):
self.conf_btn.setIcon(QIcon(plugin_path("resources", "icons", "settings.svg")))
self.del_btn.setIcon(QgsApplication.getThemeIcon("mActionDeleteSelected.svg"))
+ self.del_btn.clicked.connect(self.request_delete)
def create_widgets_for_plan_regulation(self, plan_regulation_feature: Feature):
row = self.plan_regulation_grid_layout.rowCount() + 1
for plan_regulation_config in plan_regulation_feature.attributes:
if plan_regulation_config.attribute == "type_of_plan_regulation_id":
id_label = QLabel(plan_regulation_config.display())
- print(plan_regulation_config)
+ # print(plan_regulation_config)
self.plan_regulation_grid_layout.addWidget(id_label, row, 0)
elif plan_regulation_config.attribute == "numeric_default":
if not self.input_value_header:
diff --git a/arho_feature_template/gui/template_attribute_form.py b/arho_feature_template/gui/template_attribute_form.py
index e73b257..23b10e7 100644
--- a/arho_feature_template/gui/template_attribute_form.py
+++ b/arho_feature_template/gui/template_attribute_form.py
@@ -20,7 +20,7 @@
if TYPE_CHECKING:
from qgis.PyQt.QtWidgets import QWidget
- from arho_feature_template.core.template_library_config import FeatureTemplate
+ from arho_feature_template.core.template_library_config import Feature, FeatureTemplate
ui_path = resources.files(__package__) / "template_attribute_form.ui"
FormClass, _ = uic.loadUiType(ui_path)
@@ -47,31 +47,56 @@ def __init__(self, feature_template_config: FeatureTemplate):
self.button_box.accepted.connect(self._on_ok_clicked)
# INIT
+ self.scroll_area_spacer = None
+ self.available_plan_regulation_group_configs: list[Feature] = []
+
self.setWindowTitle(feature_template_config.name)
self.init_plan_regulation_groups(feature_template_config)
+ self.init_add_plan_regulation_group_btn()
+
+ def add_spacer(self):
self.scroll_area_spacer = QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Expanding)
self.plan_regulation_group_scrollarea_contents.layout().addItem(self.scroll_area_spacer)
- self.init_add_plan_regulation_group_btn()
+
+ def remove_spacer(self):
+ if self.scroll_area_spacer is not None:
+ self.plan_regulation_group_scrollarea_contents.layout().removeItem(self.scroll_area_spacer)
+ self.scroll_area_spacer = None
+
+ def add_plan_regulation_group(self, feature_config: Feature):
+ new_plan_regulation_group = PlanRegulationGroupWidget(feature_config)
+ new_plan_regulation_group.delete_signal.connect(self.remove_plan_regulation_group)
+ self.remove_spacer()
+ self.plan_regulation_group_scrollarea_contents.layout().addWidget(new_plan_regulation_group)
+ self.add_spacer()
+
+ def remove_plan_regulation_group(self, plan_regulation_group_widget: PlanRegulationGroupWidget):
+ self.plan_regulation_group_scrollarea_contents.layout().removeWidget(plan_regulation_group_widget)
+ plan_regulation_group_widget.deleteLater()
def init_add_plan_regulation_group_btn(self):
menu = QMenu()
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 1")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 2")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 3")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 4")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 5")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 6")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 7")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 8")
- self.new_mineral_system_action = menu.addAction("Kaavamääräysryhmä 9")
+ for config in self.available_plan_regulation_group_configs:
+ plan_regulation_group_name = ""
+ for attribute in config.attributes:
+ if attribute.attribute == "name":
+ plan_regulation_group_name = attribute.display()
+
+ action = menu.addAction(plan_regulation_group_name)
+ action.triggered.connect(lambda _, config=config: self.add_plan_regulation_group(config))
self.add_plan_regulation_group_btn.setMenu(menu)
def init_plan_regulation_groups(self, feature_template_config: FeatureTemplate):
+ if feature_template_config.feature.child_features is None:
+ return
for child_feature in feature_template_config.feature.child_features:
if child_feature.layer == "plan_requlation_group":
- plan_regulation_group_entry = PlanRegulationGroupWidget(child_feature)
- self.plan_regulation_group_scrollarea_contents.layout().addWidget(plan_regulation_group_entry)
+ # Collect encountered plan regulation groups in init
+ # This does not need to be done if Katja config file is read beforehand and
+ # that handles available plan regulation groups
+ self.available_plan_regulation_group_configs.append(child_feature)
+ self.add_plan_regulation_group(child_feature)
else:
# TODO
print(f"Encountered child feature with unrecognized layer: {child_feature.layer}")
diff --git a/arho_feature_template/plugin.py b/arho_feature_template/plugin.py
index f06d83c..fc34cc2 100644
--- a/arho_feature_template/plugin.py
+++ b/arho_feature_template/plugin.py
@@ -1,16 +1,22 @@
from __future__ import annotations
+import os
from typing import TYPE_CHECKING, Callable, cast
from qgis.PyQt.QtCore import QCoreApplication, Qt, QTranslator
from qgis.PyQt.QtGui import QIcon
-from qgis.PyQt.QtWidgets import QAction, QWidget
+from qgis.PyQt.QtWidgets import QAction, QDialog, QMessageBox, QWidget
from qgis.utils import iface
from arho_feature_template.core.feature_template_library import FeatureTemplater, TemplateGeometryDigitizeMapTool
+from arho_feature_template.core.new_plan import NewPlan
+from arho_feature_template.core.update_plan import LandUsePlan, update_selected_plan
+from arho_feature_template.gui.load_plan_dialog import LoadPlanDialog
from arho_feature_template.qgis_plugin_tools.tools.custom_logging import setup_logger, teardown_logger
from arho_feature_template.qgis_plugin_tools.tools.i18n import setup_translation
from arho_feature_template.qgis_plugin_tools.tools.resources import plugin_name
+from arho_feature_template.utils.db_utils import get_existing_database_connection_names
+from arho_feature_template.utils.misc_utils import PLUGIN_PATH
if TYPE_CHECKING:
from qgis.gui import QgisInterface, QgsMapTool
@@ -25,6 +31,7 @@ class Plugin:
def __init__(self) -> None:
setup_logger(Plugin.name)
+ self.digitizing_tool = None
# initialize locale
locale, file_path = setup_translation()
@@ -120,12 +127,17 @@ def add_action(
def initGui(self) -> None: # noqa N802
self.templater = FeatureTemplater()
+ self.new_plan = NewPlan()
+
+ plan_icon_path = os.path.join(PLUGIN_PATH, "resources/icons/city.png") # A placeholder icon
+ load_icon_path = os.path.join(PLUGIN_PATH, "resources/icons/folder.png") # A placeholder icon
iface.addDockWidget(Qt.RightDockWidgetArea, self.templater.template_dock)
self.templater.template_dock.visibilityChanged.connect(self.dock_visibility_changed)
iface.mapCanvas().mapToolSet.connect(self.templater.digitize_map_tool.deactivate)
+ # Add main plugin action to the toolbar
self.template_dock_action = self.add_action(
"",
"Feature Templates",
@@ -136,10 +148,52 @@ def initGui(self) -> None: # noqa N802
add_to_toolbar=True,
)
+ self.new_land_use_plan_action = self.add_action(
+ plan_icon_path,
+ "Create New Land Use Plan",
+ self.add_new_plan,
+ add_to_menu=True,
+ add_to_toolbar=True,
+ status_tip="Create a new land use plan",
+ )
+
+ self.load_land_use_plan_action = self.add_action(
+ load_icon_path,
+ text="Load existing land use plan",
+ triggered_callback=self.load_existing_land_use_plan,
+ parent=iface.mainWindow(),
+ add_to_toolbar=True,
+ )
+
def on_map_tool_changed(self, new_tool: QgsMapTool, old_tool: QgsMapTool) -> None: # noqa: ARG002
if not isinstance(new_tool, TemplateGeometryDigitizeMapTool):
self.template_dock_action.setChecked(False)
+ def add_new_plan(self):
+ self.new_plan.add_new_plan()
+
+ def load_existing_land_use_plan(self) -> None:
+ """Open existing land use plan."""
+
+ connections = get_existing_database_connection_names()
+
+ if not connections:
+ QMessageBox.critical(None, "Error", "No database connections found.")
+ return
+
+ dialog = LoadPlanDialog(None, connections)
+
+ if dialog.exec_() == QDialog.Accepted:
+ selected_plan_id = dialog.get_selected_plan_id()
+
+ if not selected_plan_id:
+ QMessageBox.critical(None, "Error", "No plan was selected.")
+ return
+
+ plan = LandUsePlan(selected_plan_id)
+
+ update_selected_plan(plan)
+
def unload(self) -> None:
"""Removes the plugin menu item and icon from QGIS GUI."""
for action in self.actions:
diff --git a/arho_feature_template/resources/icons/city.png b/arho_feature_template/resources/icons/city.png
new file mode 100644
index 0000000..ed82aaf
Binary files /dev/null and b/arho_feature_template/resources/icons/city.png differ
diff --git a/arho_feature_template/resources/icons/folder.png b/arho_feature_template/resources/icons/folder.png
new file mode 100644
index 0000000..9eb2915
Binary files /dev/null and b/arho_feature_template/resources/icons/folder.png differ
diff --git a/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml b/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml
index 35c7aef..b6cf67f 100644
--- a/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml
+++ b/arho_feature_template/resources/template_libraries/asemakaava-sample.yaml
@@ -7,6 +7,7 @@ templates:
description: Kaavakohde ilman kikkareita
feature:
layer: land_use_area
+ # layer: Aluevaraus
attributes:
- attribute: name
- attribute: type_of_underground_id
@@ -15,6 +16,7 @@ templates:
description: Aluella kuvataan ...
feature:
layer: land_use_area
+ # layer: Aluevaraus
attributes:
- attribute: name
- attribute: type_of_underground_id
diff --git a/arho_feature_template/utils/__init__.py b/arho_feature_template/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/arho_feature_template/utils/db_utils.py b/arho_feature_template/utils/db_utils.py
new file mode 100644
index 0000000..b8f67a0
--- /dev/null
+++ b/arho_feature_template/utils/db_utils.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+import logging
+
+from qgis.core import QgsProviderRegistry
+
+from arho_feature_template.core.exceptions import UnexpectedNoneError
+
+LOGGER = logging.getLogger("LandUsePlugin")
+
+
+def get_existing_database_connection_names() -> list[str]:
+ """
+ Retrieve the list of existing database connections from QGIS settings.
+
+ :return: A set of available PostgreSQL connection names.
+ """
+
+ provider_registry = QgsProviderRegistry.instance()
+ if provider_registry is None:
+ raise UnexpectedNoneError
+ postgres_provider_metadata = provider_registry.providerMetadata("postgres")
+ if postgres_provider_metadata is None:
+ raise UnexpectedNoneError
+
+ return list(postgres_provider_metadata.dbConnections(False))
diff --git a/arho_feature_template/utils/misc_utils.py b/arho_feature_template/utils/misc_utils.py
new file mode 100644
index 0000000..42f38d6
--- /dev/null
+++ b/arho_feature_template/utils/misc_utils.py
@@ -0,0 +1,3 @@
+import os
+
+PLUGIN_PATH = os.path.dirname(os.path.dirname(__file__))