diff --git a/pg_service_parser/core/connection_model.py b/pg_service_parser/core/connection_model.py new file mode 100644 index 0000000..6c4a89d --- /dev/null +++ b/pg_service_parser/core/connection_model.py @@ -0,0 +1,67 @@ +from qgis.core import QgsAbstractDatabaseProviderConnection +from qgis.PyQt.QtCore import QAbstractTableModel, QModelIndex, Qt +from qgis.PyQt.QtGui import QFont + + +class ServiceConnectionModel(QAbstractTableModel): + KEY_COL = 0 + VALUE_COL = 1 + + def __init__( + self, service_name: str, connections: dict[str, QgsAbstractDatabaseProviderConnection] + ) -> None: + super().__init__() + self.__service_name = service_name + self.__model_data = connections + + def rowCount(self, parent=QModelIndex()): + return len(self.__model_data) + + def columnCount(self, parent=QModelIndex()): + return 2 + + def index_to_connection_key(self, index): + return list(self.__model_data.keys())[index.row()] + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + + key = list(self.__model_data.keys())[index.row()] + if role == Qt.ItemDataRole.DisplayRole: + if index.column() == self.KEY_COL: + return key + elif index.column() == self.VALUE_COL: + return self.__model_data[key].uri() + elif role == Qt.ItemDataRole.FontRole: + if index.column() == self.KEY_COL: + font = QFont() + font.setBold(True) + return font + elif index.column() == self.VALUE_COL: + font = QFont() + font.setItalic(True) + return font + elif role == Qt.ItemDataRole.ToolTipRole: + if index.column() == self.VALUE_COL: + return self.__model_data[key].uri() + + return None + + def headerData(self, section, orientation, role): + if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: + if section == self.KEY_COL: + return "Connection name" + elif section == self.VALUE_COL: + return "URI" + + return QAbstractTableModel.headerData(self, section, orientation, role) + + def flags(self, idx): + if not idx.isValid(): + return ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEnabled + + return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled + + def service_name(self): + return self.__service_name diff --git a/pg_service_parser/core/service_connections.py b/pg_service_parser/core/service_connections.py new file mode 100644 index 0000000..41d798f --- /dev/null +++ b/pg_service_parser/core/service_connections.py @@ -0,0 +1,48 @@ +from qgis.core import ( + QgsAbstractDatabaseProviderConnection, + QgsDataSourceUri, + QgsProviderRegistry, +) +from qgis.gui import QgsGui +from qgis.PyQt.QtCore import QSettings +from qgis.PyQt.QtWidgets import QWidget + + +def get_connections(service: str) -> dict[str, QgsAbstractDatabaseProviderConnection]: + res = {} + provider = QgsProviderRegistry.instance().providerMetadata("postgres") + conns = provider.connections() + for key, pg_conn in conns.items(): + if QgsDataSourceUri(pg_conn.uri()).service() == service: + res[key] = pg_conn + + return res + + +def create_connection(service: str, connection_name: str) -> None: + config = {} + uri = f"service='{service}'" + provider = QgsProviderRegistry.instance().providerMetadata("postgres") + conn = provider.createConnection(uri, config) + provider.saveConnection(conn, connection_name) + + +def remove_connection(connection_name: str) -> None: + provider = QgsProviderRegistry.instance().providerMetadata("postgres") + provider.deleteConnection(connection_name) + + +def edit_connection(connection_name: str, parent: QWidget) -> None: + provider = QgsProviderRegistry.instance().providerMetadata("postgres") + + if connection_name in provider.dbConnections(): + pg = QgsGui.sourceSelectProviderRegistry().providerByName("postgres") + widget = pg.createDataSourceWidget( + parent, widgetMode=QgsProviderRegistry.WidgetMode.Standalone + ) + + settings = QSettings() + settings.setValue("PostgreSQL/connections/selected", connection_name) + + widget.refresh() # To reflect the newly selected connection + widget.btnEdit_clicked() diff --git a/pg_service_parser/core/item_models.py b/pg_service_parser/core/setting_model.py similarity index 100% rename from pg_service_parser/core/item_models.py rename to pg_service_parser/core/setting_model.py diff --git a/pg_service_parser/gui/dlg_new_name.py b/pg_service_parser/gui/dlg_new_name.py new file mode 100644 index 0000000..031d8dc --- /dev/null +++ b/pg_service_parser/gui/dlg_new_name.py @@ -0,0 +1,42 @@ +from enum import Enum + +from qgis.PyQt.QtCore import pyqtSlot +from qgis.PyQt.QtWidgets import QDialog, QWidget + +from pg_service_parser.utils import get_ui_class + +DIALOG_UI = get_ui_class("new_name_dialog.ui") + + +class EnumNewName(Enum): + SERVICE = 0 + CONNECTION = 1 + + +class NewNameDialog(QDialog, DIALOG_UI): + + def __init__(self, mode: EnumNewName, parent: QWidget, data: str = "") -> None: + QDialog.__init__(self, parent) + self.setupUi(self) + self.__mode = mode + + self.buttonBox.accepted.connect(self.__accepted) + + if self.__mode == EnumNewName.SERVICE: + self.setWindowTitle("Service name") + self.label.setText("Enter a service name") + self.txtNewName.setPlaceholderText("e.g., my-service") + self.new_name = "my-service" + elif self.__mode == EnumNewName.CONNECTION: + self.setWindowTitle("Connection name") + self.label.setText("Enter a connection name") + self.txtNewName.setPlaceholderText("e.g., My Service Connection") + self.new_name = f"{data} connection" + + @pyqtSlot() + def __accepted(self): + if self.txtNewName.text().strip(): + if self.__mode == EnumNewName.SERVICE: + self.new_name = self.txtNewName.text().strip().replace(" ", "-") + elif self.__mode == EnumNewName.CONNECTION: + self.new_name = self.txtNewName.text().strip() diff --git a/pg_service_parser/gui/dlg_pg_service.py b/pg_service_parser/gui/dlg_pg_service.py index bf9c27e..b7b9e1a 100644 --- a/pg_service_parser/gui/dlg_pg_service.py +++ b/pg_service_parser/gui/dlg_pg_service.py @@ -2,11 +2,11 @@ from qgis.core import QgsApplication from qgis.gui import QgsMessageBar -from qgis.PyQt.QtCore import QItemSelection, Qt, pyqtSlot -from qgis.PyQt.QtWidgets import QDialog, QMessageBox, QSizePolicy +from qgis.PyQt.QtCore import QItemSelection, QModelIndex, Qt, pyqtSlot +from qgis.PyQt.QtWidgets import QDialog, QHeaderView, QMessageBox, QSizePolicy from pg_service_parser.conf.service_settings import SERVICE_SETTINGS, SETTINGS_TEMPLATE -from pg_service_parser.core.item_models import ServiceConfigModel +from pg_service_parser.core.connection_model import ServiceConnectionModel from pg_service_parser.core.pg_service_parser_wrapper import ( add_new_service, conf_path, @@ -15,7 +15,14 @@ service_names, write_service, ) -from pg_service_parser.gui.dlg_service_name import ServiceNameDialog +from pg_service_parser.core.service_connections import ( + create_connection, + edit_connection, + get_connections, + remove_connection, +) +from pg_service_parser.core.setting_model import ServiceConfigModel +from pg_service_parser.gui.dlg_new_name import EnumNewName, NewNameDialog from pg_service_parser.gui.dlg_service_settings import ServiceSettingsDialog from pg_service_parser.gui.item_delegates import ServiceConfigDelegate from pg_service_parser.utils import get_ui_class @@ -23,6 +30,7 @@ DIALOG_UI = get_ui_class("pg_service_dialog.ui") EDIT_TAB_INDEX = 0 COPY_TAB_INDEX = 1 +CONNECTION_TAB_INDEX = 2 class PgServiceDialog(QDialog, DIALOG_UI): @@ -53,9 +61,13 @@ def __initialize_dialog(self): return self.__edit_model = None + self.__connection_model = None self.btnAddSettings.setIcon(QgsApplication.getThemeIcon("/symbologyAdd.svg")) self.btnRemoveSetting.setIcon(QgsApplication.getThemeIcon("/symbologyRemove.svg")) + self.btnAddConnection.setIcon(QgsApplication.getThemeIcon("/symbologyAdd.svg")) + self.btnEditConnection.setIcon(QgsApplication.getThemeIcon("/symbologyEdit.svg")) + self.btnRemoveConnection.setIcon(QgsApplication.getThemeIcon("/symbologyRemove.svg")) self.txtConfFile.setText(str(self.__conf_file_path)) self.lblWarning.setVisible(False) self.lblConfFile.setText("Config file path found at ") @@ -63,6 +75,7 @@ def __initialize_dialog(self): self.txtConfFile.setVisible(True) self.tabWidget.setEnabled(True) self.btnCreateServiceFile.setVisible(False) + self.tblServiceConnections.horizontalHeader().setVisible(True) self.btnRemoveSetting.setEnabled(False) self.radOverwrite.toggled.connect(self.__update_target_controls) @@ -73,9 +86,15 @@ def __initialize_dialog(self): self.btnAddSettings.clicked.connect(self.__add_settings_clicked) self.btnRemoveSetting.clicked.connect(self.__remove_setting_clicked) self.btnUpdateService.clicked.connect(self.__update_service_clicked) + self.cboConnectionService.currentIndexChanged.connect(self.__connection_service_changed) + self.btnAddConnection.clicked.connect(self.__add_connection_clicked) + self.btnEditConnection.clicked.connect(self.__edit_connection_clicked) + self.btnRemoveConnection.clicked.connect(self.__remove_connection_clicked) + self.tblServiceConnections.doubleClicked.connect(self.__edit_double_clicked_connection) self.__initialize_edit_services() self.__initialize_copy_services() + self.__initialize_connection_services() self.__update_target_controls(True) self.__update_add_settings_button() @@ -85,11 +104,11 @@ def __initialize_dialog(self): @pyqtSlot() def __create_file_clicked(self): - dlg = ServiceNameDialog(self) + dlg = NewNameDialog(EnumNewName.SERVICE, self) dlg.exec() if dlg.result() == QDialog.DialogCode.Accepted: Path.touch(self.__conf_file_path) - add_new_service(dlg.service_name) + add_new_service(dlg.new_name) # Set flag to get a template after some initialization self.__new_empty_file = True @@ -138,6 +157,15 @@ def __initialize_edit_services(self): self.cboEditService.addItems(service_names(self.__conf_file_path)) self.cboEditService.setCurrentText(current_text) + def __initialize_connection_services(self): + self.__connection_model = None + current_text = self.cboConnectionService.currentText() # Remember latest currentText + self.cboConnectionService.blockSignals(True) # Avoid triggering custom slot while clearing + self.cboConnectionService.clear() + self.cboConnectionService.blockSignals(False) + self.cboConnectionService.addItems(service_names(self.__conf_file_path)) + self.cboConnectionService.setCurrentText(current_text) + @pyqtSlot() def __copy_service(self): # Validations @@ -178,6 +206,8 @@ def __current_tab_changed(self, index): pass # For now, services to be copied won't be altered in other tabs elif index == EDIT_TAB_INDEX: self.__initialize_edit_services() + elif index == CONNECTION_TAB_INDEX: + self.__initialize_connection_services() @pyqtSlot(int) def __edit_service_changed(self, index): @@ -275,3 +305,72 @@ def __update_service_clicked(self): self.__edit_model.set_not_dirty() else: self.bar.pushInfo("PG service", "Edit the service configuration and try again.") + + @pyqtSlot(int) + def __connection_service_changed(self, index): + self.__initialize_service_connections() + + def __initialize_service_connections(self, selected_index=QModelIndex()): + service = self.cboConnectionService.currentText() + self.__connection_model = ServiceConnectionModel(service, get_connections(service)) + self.__update_connection_controls(False) + self.tblServiceConnections.setModel(self.__connection_model) + self.tblServiceConnections.horizontalHeader().setSectionResizeMode( + 0, QHeaderView.ResizeToContents + ) + + self.tblServiceConnections.selectionModel().selectionChanged.connect( + self.__conn_table_selection_changed + ) + self.tblServiceConnections.selectRow(selected_index.row()) # Remember selection + + @pyqtSlot() + def __add_connection_clicked(self): + service = self.cboConnectionService.currentText() + dlg = NewNameDialog(EnumNewName.CONNECTION, self, service) + dlg.exec() + if dlg.result() == QDialog.DialogCode.Accepted: + create_connection(service, dlg.new_name) + self.__initialize_service_connections() + + @pyqtSlot() + def __edit_connection_clicked(self): + selected_indexes = self.tblServiceConnections.selectedIndexes() + if selected_indexes: + self.__edit_connection(selected_indexes[0]) + + @pyqtSlot(QModelIndex) + def __edit_double_clicked_connection(self, index): + self.__edit_connection(index) + + def __edit_connection(self, index): + connection_name = self.__connection_model.index_to_connection_key(index) + edit_connection(connection_name, self) + self.__initialize_service_connections(index) + + @pyqtSlot() + def __remove_connection_clicked(self): + selected_indexes = self.tblServiceConnections.selectedIndexes() + if selected_indexes: + connection_name = self.__connection_model.index_to_connection_key(selected_indexes[0]) + if ( + QMessageBox.question( + self, + "Remove service connection", + f"Are you sure you want to remove the connection to '{connection_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + == QMessageBox.StandardButton.Yes + ): + remove_connection(connection_name) + self.__initialize_service_connections() + + @pyqtSlot(QItemSelection, QItemSelection) + def __conn_table_selection_changed(self, selected, deselected): + selected_indexes = bool(self.tblServiceConnections.selectedIndexes()) + self.__update_connection_controls(selected_indexes) + + def __update_connection_controls(self, enable): + self.btnEditConnection.setEnabled(enable) + self.btnRemoveConnection.setEnabled(enable) diff --git a/pg_service_parser/gui/dlg_service_name.py b/pg_service_parser/gui/dlg_service_name.py deleted file mode 100644 index edb7798..0000000 --- a/pg_service_parser/gui/dlg_service_name.py +++ /dev/null @@ -1,21 +0,0 @@ -from qgis.PyQt.QtCore import pyqtSlot -from qgis.PyQt.QtWidgets import QDialog - -from pg_service_parser.utils import get_ui_class - -DIALOG_UI = get_ui_class("service_name_dialog.ui") - - -class ServiceNameDialog(QDialog, DIALOG_UI): - - def __init__(self, parent): - QDialog.__init__(self, parent) - self.setupUi(self) - - self.buttonBox.accepted.connect(self.__accepted) - self.service_name = "my-service" - - @pyqtSlot() - def __accepted(self): - if self.txtServiceName.text().strip(): - self.service_name = self.txtServiceName.text().replace(" ", "-") diff --git a/pg_service_parser/gui/item_delegates.py b/pg_service_parser/gui/item_delegates.py index 04767fd..66f1758 100644 --- a/pg_service_parser/gui/item_delegates.py +++ b/pg_service_parser/gui/item_delegates.py @@ -4,7 +4,7 @@ from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtWidgets import QComboBox, QStyledItemDelegate -from pg_service_parser.core.item_models import ServiceConfigModel +from pg_service_parser.core.setting_model import ServiceConfigModel class ServiceConfigDelegate(QStyledItemDelegate): diff --git a/pg_service_parser/ui/service_name_dialog.ui b/pg_service_parser/ui/new_name_dialog.ui similarity index 86% rename from pg_service_parser/ui/service_name_dialog.ui rename to pg_service_parser/ui/new_name_dialog.ui index 69a0c96..bf54cf3 100644 --- a/pg_service_parser/ui/service_name_dialog.ui +++ b/pg_service_parser/ui/new_name_dialog.ui @@ -1,7 +1,7 @@ - dlgServiceName - + dlgNewName + 0 @@ -11,23 +11,23 @@ - Service name + New name - Enter a service name + Enter a new name - + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - e.g., my-service + e.g., new name true @@ -66,7 +66,7 @@ buttonBox accepted() - dlgServiceName + dlgNewName accept() @@ -82,7 +82,7 @@ buttonBox rejected() - dlgServiceName + dlgNewName reject() diff --git a/pg_service_parser/ui/pg_service_dialog.ui b/pg_service_parser/ui/pg_service_dialog.ui index c43e301..c50ebb1 100644 --- a/pg_service_parser/ui/pg_service_dialog.ui +++ b/pg_service_parser/ui/pg_service_dialog.ui @@ -6,7 +6,7 @@ 0 0 - 497 + 499 316 @@ -86,7 +86,7 @@ - Edit + Edit Service @@ -228,7 +228,7 @@ - Copy + Copy Service @@ -326,6 +326,143 @@ + + + QGIS Connections + + + + + + + + Service + + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed + + + true + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + Qt::DotLine + + + false + + + false + + + true + + + false + + + + + + + + + + 25 + 25 + + + + Add connection to current service + + + + + + + + + + + 25 + 25 + + + + Edit connection + + + + + + + + + + + 25 + 25 + + + + Remove connection to current service + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + +