diff --git a/qgepplugin/gui/qgepdatamodeldialog.py b/qgepplugin/gui/qgepdatamodeldialog.py new file mode 100644 index 00000000..ca90fc29 --- /dev/null +++ b/qgepplugin/gui/qgepdatamodeldialog.py @@ -0,0 +1,890 @@ +# -*- coding: utf-8 -*- +# ----------------------------------------------------------- +# +# Profile +# Copyright (C) 2021 Olivier Dalang +# ----------------------------------------------------------- +# +# licensed under the terms of GNU GPL 2 +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, print to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +# --------------------------------------------------------------------- + +import configparser +import functools +import os +import subprocess +import sys +import tempfile +import zipfile + +import pkg_resources +import psycopg2 +from qgis.core import Qgis, QgsMessageLog, QgsNetworkAccessManager, QgsProject +from qgis.PyQt.QtCore import QFile, QIODevice, QUrl +from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest +from qgis.PyQt.QtWidgets import ( + QApplication, + QDialog, + QMessageBox, + QProgressDialog, + QPushButton, +) + +from ..utils import get_ui_class + +# Currently, the latest release is hard-coded in the plugin, meaning we need +# to publish a plugin update for each datamodel update. +# In the future, once plugin/datamodel versionning scheme clearly reflects +# compatibility, we could retrieve this dynamically, so datamodel bugfix +# releases don't require a plugin upgrade. +LATEST_RELEASE = "1.5.4" + +# Allow to choose which releases can be installed +# (not so useful... but may want allow picking master for pre-releases) +AVAILABLE_RELEASES = { + # 'master': 'https://github.com/QGEP/datamodel/archive/master.zip', # TODO : if we expose this here, we should put a big red warning and not take it default + LATEST_RELEASE: f"https://github.com/QGEP/datamodel/archive/{LATEST_RELEASE}.zip", +} +# Allows to pick which QGIS project matches the version (will take the biggest <= match) +DATAMODEL_QGEP_VERSIONS = { + "1.5.0": "v8.0", + "1.4.0": "v7.0", + "0": "v6.2", +} +TEMP_DIR = os.path.join(tempfile.gettempdir(), "QGEP", "datamodel-init") + +# Path for pg_service.conf +if os.environ.get("PGSERVICEFILE"): + PG_CONFIG_PATH = os.environ.get("PGSERVICEFILE") +elif os.environ.get("PGSYSCONFDIR"): + PG_CONFIG_PATH = os.path.join(os.environ.get("PGSYSCONFDIR"), "pg_service.conf") +else: + PG_CONFIG_PATH = " ~/.pg_service.conf" + +MAIN_DATAMODEL_RELEASE = "1.5.2" +QGEP_RELEASE = "8.0" + +# Derived urls/paths, may require adaptations if release structure changes +DATAMODEL_URL_TEMPLATE = "https://github.com/QGEP/datamodel/archive/{}.zip" +REQUIREMENTS_PATH_TEMPLATE = os.path.join(TEMP_DIR, "datamodel-{}", "requirements.txt") +DELTAS_PATH_TEMPLATE = os.path.join(TEMP_DIR, "datamodel-{}", "delta") +INIT_SCRIPT_URL_TEMPLATE = "https://github.com/QGEP/datamodel/releases/download/{}/qgep_{}_structure_with_value_lists.sql" +QGEP_PROJECT_URL_TEMPLATE = "https://github.com/QGEP/QGEP/releases/download/{}/qgep.zip" +QGEP_PROJECT_PATH_TEMPLATE = os.path.join(TEMP_DIR, "project", "qgep.qgs") + + +def qgep_datamodel_error_catcher(func): + """Display QGEPDatamodelError in error messages rather than normal exception dialog""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except QGEPDatamodelError as e: + args[0]._show_error(str(e)) + + return wrapper + + +class QGEPDatamodelError(Exception): + pass + + +class QgepPgserviceEditorDialog(QDialog, get_ui_class("qgeppgserviceeditordialog.ui")): + def __init__(self, cur_name, cur_config, taken_names): + super().__init__() + self.setupUi(self) + self.taken_names = taken_names + self.nameLineEdit.textChanged.connect(self.check_name) + self.pgconfigUserCheckBox.toggled.connect(self.pgconfigUserLineEdit.setEnabled) + self.pgconfigPasswordCheckBox.toggled.connect( + self.pgconfigPasswordLineEdit.setEnabled + ) + + self.nameLineEdit.setText(cur_name) + self.pgconfigHostLineEdit.setText(cur_config.get("host", "")) + self.pgconfigPortLineEdit.setText(cur_config.get("port", "")) + self.pgconfigDbLineEdit.setText(cur_config.get("dbname", "")) + self.pgconfigUserLineEdit.setText(cur_config.get("user", "")) + self.pgconfigPasswordLineEdit.setText(cur_config.get("password", "")) + + self.pgconfigUserCheckBox.setChecked(cur_config.get("user") is not None) + self.pgconfigPasswordCheckBox.setChecked(cur_config.get("password") is not None) + self.pgconfigUserLineEdit.setEnabled(cur_config.get("user") is not None) + self.pgconfigPasswordLineEdit.setEnabled(cur_config.get("password") is not None) + + self.check_name(cur_name) + + def check_name(self, new_text): + if new_text in self.taken_names: + self.nameCheckLabel.setText("will overwrite") + self.nameCheckLabel.setStyleSheet( + "color: rgb(170, 95, 0);\nfont-weight: bold;" + ) + else: + self.nameCheckLabel.setText("will be created") + self.nameCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + + def conf_name(self): + return self.nameLineEdit.text() + + def conf_dict(self): + retval = { + "host": self.pgconfigHostLineEdit.text(), + "port": self.pgconfigPortLineEdit.text(), + "dbname": self.pgconfigDbLineEdit.text(), + } + if self.pgconfigUserCheckBox.isChecked(): + retval.update( + { + "user": self.pgconfigUserLineEdit.text(), + } + ) + if self.pgconfigPasswordCheckBox.isChecked(): + retval.update( + { + "password": self.pgconfigPasswordLineEdit.text(), + } + ) + return retval + + +class QgepDatamodelInitToolDialog(QDialog, get_ui_class("qgepdatamodeldialog.ui")): + def __init__(self, parent=None): + QDialog.__init__(self, parent) + self.setupUi(self) + + self.progress_dialog = None + + # Populate the versions + self.releaseVersionComboBox.clear() + if len(AVAILABLE_RELEASES) > 1: + self.releaseVersionComboBox.addItem("- SELECT RELEASE VERSION -") + self.releaseVersionComboBox.model().item(0).setEnabled(False) + for version in sorted(list(AVAILABLE_RELEASES.keys()), reverse=True): + self.releaseVersionComboBox.addItem(version) + self.releaseVersionComboBox.setEnabled(len(AVAILABLE_RELEASES) > 1) + + # Show the pgconfig path + self.pgservicePathLabel.setText(PG_CONFIG_PATH) + + # Connect some signals + + self.releaseVersionComboBox.activated.connect(self.switch_datamodel) + + self.installDepsButton.pressed.connect(self.install_requirements) + + self.pgserviceComboBox.activated.connect(self.select_pgconfig) + self.pgserviceAddButton.pressed.connect(self.add_pgconfig) + + self.targetVersionComboBox.activated.connect(self.check_version) + self.versionUpgradeButton.pressed.connect(self.upgrade_version) + self.initializeButton.pressed.connect(self.initialize_version) + + self.loadProjectButton.pressed.connect(self.load_project) + + # Initialize the checks + self.checks = { + "datamodel": False, + "requirements": False, + "pgconfig": False, + "current_version": False, + "project": False, + } + + if len(AVAILABLE_RELEASES) == 1: + self.switch_datamodel() + + # Properties + + @property + def version(self): + return self.releaseVersionComboBox.currentText() + + @property + def target_version(self): + return self.targetVersionComboBox.currentText() + + @property + def conf(self): + return self.pgserviceComboBox.currentData() + + # Feedback helpers + + def _show_progress(self, message): + if self.progress_dialog is None: + self.progress_dialog = QProgressDialog( + self.tr("Starting..."), self.tr("Cancel"), 0, 0 + ) + cancel_button = QPushButton(self.tr("Cancel")) + cancel_button.setEnabled(False) + self.progress_dialog.setCancelButton(cancel_button) + self.progress_dialog.setLabelText(message) + self.progress_dialog.show() + QApplication.processEvents() + + def _done_progress(self): + self.progress_dialog.close() + self.progress_dialog.deleteLater() + self.progress_dialog = None + QApplication.processEvents() + + def _show_error(self, message): + self._done_progress() + err = QMessageBox() + err.setText(message) + err.setIcon(QMessageBox.Warning) + err.exec_() + + # Actions helpers + + def _run_sql( + self, + connection_string, + sql_command, + autocommit=False, + error_message="Psycopg error, see logs for more information", + ): + QgsMessageLog.logMessage( + f"Running query against {connection_string}: {sql_command}", "QGEP" + ) + try: + conn = psycopg2.connect(connection_string) + if autocommit: + conn.autocommit = True + cur = conn.cursor() + cur.execute(sql_command) + conn.commit() + cur.close() + conn.close() + except psycopg2.OperationalError as e: + message = f"{error_message}\nCommand :\n{sql_command}\n{e}" + raise QGEPDatamodelError(message) + + def _run_cmd( + self, + shell_command, + cwd=None, + error_message="Subprocess error, see logs for more information", + timeout=10, + ): + """ + Helper to run commands through subprocess + """ + QgsMessageLog.logMessage(f"Running command : {shell_command}", "QGEP") + result = subprocess.run( + shell_command, cwd=cwd, shell=True, capture_output=True, timeout=timeout + ) + if result.stdout: + QgsMessageLog.logMessage( + result.stdout.decode(sys.getdefaultencoding()), "QGEP" + ) + if result.stderr: + QgsMessageLog.logMessage( + result.stderr.decode(sys.getdefaultencoding()), + "QGEP", + level=Qgis.Critical, + ) + if result.returncode: + message = f"{error_message}\nCommand :\n{shell_command}" + if result.stdout: + message += ( + f"\n\nOutput :\n{result.stdout.decode(sys.getdefaultencoding())}" + ) + if result.stderr: + message += ( + f"\n\nError :\n{result.stderr.decode(sys.getdefaultencoding())}" + ) + raise QGEPDatamodelError(message) + return result.stdout.decode(sys.getdefaultencoding()) + + def _download(self, url, filename, error_message=None): + os.makedirs(TEMP_DIR, exist_ok=True) + + network_manager = QgsNetworkAccessManager.instance() + reply = network_manager.blockingGet(QNetworkRequest(QUrl(url))) + if reply.error() != QNetworkReply.NoError: + if error_message: + error_message = f"{error_message}\n{reply.errorString()}" + else: + error_message = reply.errorString() + raise QGEPDatamodelError(error_message) + download_path = os.path.join(TEMP_DIR, filename) + QgsMessageLog.logMessage(f"Downloading {url} to {download_path}", "QGEP") + download_file = QFile(download_path) + download_file.open(QIODevice.WriteOnly) + download_file.write(reply.content()) + download_file.close() + return download_file.fileName() + + def _read_pgservice(self): + config = configparser.ConfigParser() + if os.path.exists(PG_CONFIG_PATH): + config.read(PG_CONFIG_PATH) + return config + + def _write_pgservice_conf(self, service_name, config_dict): + config = self._read_pgservice() + config[service_name] = config_dict + + class EqualsSpaceRemover: + # see https://stackoverflow.com/a/25084055/13690651 + output_file = None + + def __init__(self, output_file): + self.output_file = output_file + + def write(self, content): + content = content.replace(" = ", "=", 1) + self.output_file.write(content.encode("utf-8")) + + config.write(EqualsSpaceRemover(open(PG_CONFIG_PATH, "wb"))) + + def _get_current_version(self): + # Dirty parsing of pum info + deltas_dir = DELTAS_PATH_TEMPLATE.format(self.version) + if not os.path.exists(deltas_dir): + return None + + pum_info = self._run_cmd( + f"python -m pum info -p {self.conf} -t qgep_sys.pum_info -d {deltas_dir}", + error_message="Could not get current version, are you sure the database is accessible ?", + ) + version = None + for line in pum_info.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split("|") + if len(parts) > 1: + version = parts[1].strip() + return version + + # Display + + def showEvent(self, event): + self.update_pgconfig_combobox() + self.check_datamodel() + self.check_requirements() + self.check_pgconfig() + self.check_version() + self.check_project() + super().showEvent(event) + + def enable_buttons_if_ready(self): + self.installDepsButton.setEnabled( + self.checks["datamodel"] and not self.checks["requirements"] + ) + self.versionUpgradeButton.setEnabled(all(self.checks.values())) + self.loadProjectButton.setEnabled(self.checks["project"]) + + # Datamodel + + def check_datamodel(self): + requirements_exists = os.path.exists( + REQUIREMENTS_PATH_TEMPLATE.format(self.version) + ) + deltas_exists = os.path.exists(DELTAS_PATH_TEMPLATE.format(self.version)) + + check = requirements_exists and deltas_exists + + if check: + self.releaseCheckLabel.setText("ok") + self.releaseCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + else: + self.releaseCheckLabel.setText("not found") + self.releaseCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + self.checks["datamodel"] = check + self.enable_buttons_if_ready() + + return check + + @qgep_datamodel_error_catcher + def switch_datamodel(self, _=None): + # Download the datamodel if it doesn't exist + + if not self.check_datamodel(): + + self._show_progress("Downloading the release") + + # Download files + datamodel_path = self._download( + AVAILABLE_RELEASES[self.version], "datamodel.zip" + ) + + # Unzip + datamodel_zip = zipfile.ZipFile(datamodel_path) + datamodel_zip.extractall(TEMP_DIR) + + # Cleanup + # os.unlink(datamodel_path) + + # Update UI + self.check_datamodel() + + self._done_progress() + + self.check_requirements() + self.check_version() + + # Requirements + + def check_requirements(self): + + missing = [] + if not self.check_datamodel(): + missing.append(("unknown", "no datamodel")) + else: + requirements = pkg_resources.parse_requirements( + open(REQUIREMENTS_PATH_TEMPLATE.format(self.version)) + ) + for requirement in requirements: + try: + pkg_resources.require(str(requirement)) + except pkg_resources.DistributionNotFound: + missing.append((requirement, "missing")) + except pkg_resources.VersionConflict: + missing.append((requirement, "conflict")) + + check = len(missing) == 0 + + if check: + self.pythonCheckLabel.setText("ok") + self.pythonCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + else: + self.pythonCheckLabel.setText( + "\n".join(f"{dep}: {err}" for dep, err in missing) + ) + self.pythonCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + self.checks["requirements"] = check + self.enable_buttons_if_ready() + + return check + + @qgep_datamodel_error_catcher + def install_requirements(self): + + # TODO : Ideally, this should be done in a venv, as to avoid permission issues and/or modification + # of libraries versions that could affect other parts of the system. + # We could initialize a venv in the user's directory, and activate it. + # It's almost doable when only running commands from the command line (in which case we could + # just prepent something like `path/to/venv/Scripts/activate && ` to commands, /!\ syntax differs on Windows), + # but to be really useful, it would be best to then enable the virtualenv from within python directly. + # It seems venv doesn't provide a way to do so, while virtualenv does + # (see https://stackoverflow.com/a/33637378/13690651) + # but virtualenv isn't in the stdlib... So we'd have to install it globally ! Argh... + # Anyway, pip deps support should be done in QGIS one day so all plugins can benefit. + # In the mean time we just install globally and hope for the best. + + self._show_progress("Installing python dependencies with pip") + + # Install dependencies + requirements_file_path = REQUIREMENTS_PATH_TEMPLATE.format(self.version) + QgsMessageLog.logMessage( + f"Installing python dependencies from {requirements_file_path}", "QGEP" + ) + dependencies = " ".join( + [ + f'"{l.strip()}"' + for l in open(requirements_file_path, "r").read().splitlines() + if l.strip() + ] + ) + command_line = "the OSGeo4W shell" if os.name == "nt" else "the terminal" + self._run_cmd( + f"python -m pip install --user {dependencies}", + error_message=f"Could not install python dependencies. You can try to run the command manually from {command_line}.", + timeout=None, + ) + + self._done_progress() + + # Update UI + self.check_requirements() + + # Pgservice + + def check_pgconfig(self): + + check = bool(self.pgserviceComboBox.currentData()) + if check: + self.pgconfigCheckLabel.setText("ok") + self.pgconfigCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + else: + self.pgconfigCheckLabel.setText("not set") + self.pgconfigCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + self.checks["pgconfig"] = check + self.enable_buttons_if_ready() + + return check + + def add_pgconfig(self): + taken_names = self._read_pgservice().sections() + cur_config = self._read_pgservice()[self.conf] + add_dialog = QgepPgserviceEditorDialog(self.conf, cur_config, taken_names) + if add_dialog.exec_() == QDialog.Accepted: + name = add_dialog.conf_name() + conf = add_dialog.conf_dict() + self._write_pgservice_conf(name, conf) + self.update_pgconfig_combobox() + self.pgserviceComboBox.setCurrentIndex( + self.pgserviceComboBox.findData(name) + ) + self.select_pgconfig() + + def update_pgconfig_combobox(self): + self.pgserviceComboBox.clear() + for config_name in self._read_pgservice().sections(): + self.pgserviceComboBox.addItem(config_name, config_name) + self.pgserviceComboBox.setCurrentIndex(0) + + def select_pgconfig(self, _=None): + config = self._read_pgservice() + if self.conf in config.sections(): + host = config.get(self.conf, "host", fallback="-") + port = config.get(self.conf, "port", fallback="-") + dbname = config.get(self.conf, "dbname", fallback="-") + user = config.get(self.conf, "user", fallback="-") + password = ( + len(config.get(self.conf, "password", fallback="")) * "*" + ) or "-" + self.pgserviceCurrentLabel.setText( + f"host: {host}:{port}\ndbname: {dbname}\nuser: {user}\npassword: {password}" + ) + else: + self.pgserviceCurrentLabel.setText("-") + self.check_pgconfig() + self.check_version() + self.check_project() + + # Version + + @qgep_datamodel_error_catcher + def check_version(self, _=None): + check = False + + # target version + + # (re)populate the combobox + prev = self.targetVersionComboBox.currentText() + self.targetVersionComboBox.clear() + available_versions = set() + deltas_dir = DELTAS_PATH_TEMPLATE.format(self.version) + if os.path.exists(deltas_dir): + for f in os.listdir(deltas_dir): + if f.startswith("delta_"): + available_versions.add(f.split("_")[1]) + for available_version in sorted(list(available_versions), reverse=True): + self.targetVersionComboBox.addItem(available_version) + self.targetVersionComboBox.setCurrentText(prev) # restore + + target_version = self.targetVersionComboBox.currentText() + + # current version + + self.initializeButton.setVisible(False) + self.targetVersionComboBox.setVisible(True) + self.versionUpgradeButton.setVisible(True) + + pgservice = self.pgserviceComboBox.currentData() + if not pgservice: + self.versionCheckLabel.setText("service not selected") + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + elif not available_versions: + self.versionCheckLabel.setText("no delta in datamodel") + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + else: + + error = None + current_version = None + connection_works = True + + try: + current_version = self._get_current_version() + except QGEPDatamodelError: + # Can happend if PUM is not initialized, unfortunately we can't really + # determine if this is a connection error or if PUM is not initailized + # see https://github.com/opengisch/pum/issues/96 + # We'll try to connect to see if it's a connection error + error = "qgep not initialized" + try: + self._run_sql( + f"service={self.conf}", + "SELECT 1;", + error_message="Errors when initializing the database.", + ) + except QGEPDatamodelError: + error = "database does not exist" + try: + self._run_sql( + f"service={self.conf} dbname=postgres", + "SELECT 1;", + error_message="Errors when initializing the database.", + ) + except QGEPDatamodelError: + error = "could not connect to database" + connection_works = False + + if not connection_works: + check = False + self.versionCheckLabel.setText(error) + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + elif error is not None: + check = False + self.versionCheckLabel.setText(error) + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 95, 0);\nfont-weight: bold;" + ) + elif current_version <= target_version: + check = True + self.versionCheckLabel.setText(current_version) + self.versionCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + elif current_version > target_version: + check = False + self.versionCheckLabel.setText(f"{current_version} (cannot downgrade)") + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + else: + check = False + self.versionCheckLabel.setText(f"{current_version} (invalid version)") + self.versionCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + self.initializeButton.setVisible( + current_version is None and connection_works + ) + self.targetVersionComboBox.setVisible(current_version is not None) + self.versionUpgradeButton.setVisible(current_version is not None) + + self.checks["current_version"] = check + self.enable_buttons_if_ready() + + return check + + @qgep_datamodel_error_catcher + def initialize_version(self): + + confirm = QMessageBox() + confirm.setText( + f"You are about to initialize the datamodel on {self.conf} to version {self.version}. " + ) + confirm.setInformativeText( + "Please confirm that you have a backup of your data as this operation can result in data loss." + ) + confirm.setStandardButtons(QMessageBox.Apply | QMessageBox.Cancel) + confirm.setIcon(QMessageBox.Warning) + + if confirm.exec_() == QMessageBox.Apply: + + self._show_progress("Initializing the datamodel") + + srid = self.sridLineEdit.text() + + # If we can't get current version, it's probably that the DB is not initialized + # (or maybe we can't connect, but we can't know easily with PUM) + + self._show_progress("Initializing the datamodel") + + # TODO : this should be done by PUM directly (see https://github.com/opengisch/pum/issues/94) + # also currently SRID doesn't work + try: + self._show_progress("Downloading the structure script") + url = INIT_SCRIPT_URL_TEMPLATE.format(self.version, self.version) + sql_path = self._download( + url, + f"structure_with_value_lists-{self.version}-{srid}.sql", + error_message=f"Initialization release file not found for version {self.version}", + ) + + # Dirty hack to customize SRID in a dump + if srid != "2056": + with open(sql_path, "r") as file: + contents = file.read() + contents = contents.replace("2056", srid) + with open(sql_path, "w") as file: + file.write(contents) + + try: + conn = psycopg2.connect(f"service={self.conf}") + except psycopg2.Error: + # It may be that the database doesn't exist yet + # in that case, we try to connect to the postgres database and to create it from there + self._show_progress("Creating the database") + dbname = self._read_pgservice()[self.conf]["dbname"] + self._run_sql( + f"service={self.conf} dbname=postgres", + f"CREATE DATABASE {dbname};", + autocommit=True, + error_message="Could not create a new database.", + ) + + self._show_progress("Running the initialization scripts") + self._run_sql( + f"service={self.conf}", + "CREATE EXTENSION IF NOT EXISTS postgis;", + error_message="Errors when initializing the database.", + ) + # we cannot use this, as it doesn't support COPY statements + # this means we'll run through psql without transaction :-/ + # cur.execute(open(sql_path, "r").read()) + self._run_cmd( + f'psql -f {sql_path} "service={self.conf}"', + error_message="Errors when initializing the database.", + timeout=300, + ) + # workaround until https://github.com/QGEP/QGEP/issues/612 is fixed + self._run_sql( + f"service={self.conf}", + "SELECT qgep_network.refresh_network_simple();", + error_message="Errors when initializing the database.", + ) + + except psycopg2.Error as e: + raise QGEPDatamodelError(str(e)) + + self.check_version() + self.check_project() + + self._done_progress() + + success = QMessageBox() + success.setText("Datamodel successfully initialized") + success.setIcon(QMessageBox.Information) + success.exec_() + + @qgep_datamodel_error_catcher + def upgrade_version(self): + + confirm = QMessageBox() + confirm.setText( + f"You are about to update the datamodel on {self.conf} to version {self.target_version}. " + ) + confirm.setInformativeText( + "Please confirm that you have a backup of your data as this operation can result in data loss." + ) + confirm.setStandardButtons(QMessageBox.Apply | QMessageBox.Cancel) + confirm.setIcon(QMessageBox.Warning) + + if confirm.exec_() == QMessageBox.Apply: + + self._show_progress("Upgrading the datamodel") + + srid = self.sridLineEdit.text() + + self._show_progress("Running pum upgrade") + deltas_dir = DELTAS_PATH_TEMPLATE.format(self.version) + return self._run_cmd( + f"python -m pum upgrade -p {self.conf} -t qgep_sys.pum_info -d {deltas_dir} -u {self.target_version} -v int SRID {srid}", + cwd=os.path.dirname(deltas_dir), + error_message="Errors when upgrading the database.", + timeout=300, + ) + + self.check_version() + + self._done_progress() + + success = QMessageBox() + success.setText("Datamodel successfully upgraded") + success.setIcon(QMessageBox.Information) + success.exec_() + + # Project + + @qgep_datamodel_error_catcher + def check_project(self): + + try: + current_version = self._get_current_version() + except QGEPDatamodelError: + # Can happend if PUM is not initialized, unfortunately we can't really + # determine if this is a connection error or if PUM is not initailized + # see https://github.com/opengisch/pum/issues/96 + current_version = None + + check = current_version is not None + + if check: + self.projectCheckLabel.setText("ok") + self.projectCheckLabel.setStyleSheet( + "color: rgb(0, 170, 0);\nfont-weight: bold;" + ) + else: + self.projectCheckLabel.setText("version not found") + self.projectCheckLabel.setStyleSheet( + "color: rgb(170, 0, 0);\nfont-weight: bold;" + ) + + self.checks["project"] = check + self.enable_buttons_if_ready() + + return check + + @qgep_datamodel_error_catcher + def load_project(self): + + current_version = self._get_current_version() + + qgis_vers = None + for dm_vers in sorted(DATAMODEL_QGEP_VERSIONS): + if dm_vers <= current_version: + qgis_vers = DATAMODEL_QGEP_VERSIONS[dm_vers] + + url = QGEP_PROJECT_URL_TEMPLATE.format(qgis_vers) + qgep_path = self._download(url, "qgep.zip") + qgep_zip = zipfile.ZipFile(qgep_path) + qgep_zip.extractall(TEMP_DIR) + + with open(QGEP_PROJECT_PATH_TEMPLATE, "r") as original_project: + contents = original_project.read() + + # replace the service name + contents = contents.replace("service='pg_qgep'", f"service='{self.conf}'") + + output_file = tempfile.NamedTemporaryFile(suffix=".qgs", delete=False) + output_file.write(contents.encode("utf8")) + + QgsProject.instance().read(output_file.name) diff --git a/qgepplugin/gui/qgepsettingsdialog.py b/qgepplugin/gui/qgepsettingsdialog.py index 6018531d..38e25885 100644 --- a/qgepplugin/gui/qgepsettingsdialog.py +++ b/qgepplugin/gui/qgepsettingsdialog.py @@ -57,6 +57,9 @@ def __init__(self, parent=None): develmode = self.settings.value("/QGEP/DeveloperMode", False, type=bool) self.mCbDevelMode.setChecked(develmode) + adminmode = self.settings.value("/QGEP/AdminMode", False, type=bool) + self.mCbAdminMode.setChecked(adminmode) + lyr_special_structures, _ = project.readEntry("QGEP", "SpecialStructureLayer") lyr_graph_edges, _ = project.readEntry("QGEP", "GraphEdgeLayer") lyr_graph_nodes, _ = project.readEntry("QGEP", "GraphNodeLayer") @@ -117,6 +120,7 @@ def onAccept(self): self.settings.remove("/QGEP/SvgProfilePath") self.settings.setValue("/QGEP/DeveloperMode", self.mCbDevelMode.checkState()) + self.settings.setValue("/QGEP/AdminMode", self.mCbAdminMode.checkState()) # Logging if hasattr(qgeplogger, "qgepFileHandler"): diff --git a/qgepplugin/qgepplugin.py b/qgepplugin/qgepplugin.py index d5fdda9a..79626af4 100644 --- a/qgepplugin/qgepplugin.py +++ b/qgepplugin/qgepplugin.py @@ -36,6 +36,7 @@ from qgis.PyQt.QtWidgets import QAction, QApplication, QToolBar from qgis.utils import qgsfunction +from .gui.qgepdatamodeldialog import QgepDatamodelInitToolDialog from .gui.qgepplotsvgwidget import QgepPlotSVGWidget from .gui.qgepprofiledockwidget import QgepProfileDockWidget from .gui.qgepsettingsdialog import QgepSettingsDialog @@ -230,6 +231,11 @@ def initGui(self): self.settingsAction = QAction(self.tr("Settings"), self.iface.mainWindow()) self.settingsAction.triggered.connect(self.showSettings) + self.datamodelInitToolAction = QAction( + self.tr("Datamodel tool"), self.iface.mainWindow() + ) + self.datamodelInitToolAction.triggered.connect(self.showDatamodelInitTool) + # Add toolbar button and menu item self.toolbar = QToolBar(QApplication.translate("qgepplugin", "QGEP")) self.toolbar.addAction(self.profileAction) @@ -242,6 +248,8 @@ def initGui(self): self.iface.addPluginToMenu("&QGEP", self.profileAction) self.iface.addPluginToMenu("&QGEP", self.settingsAction) self.iface.addPluginToMenu("&QGEP", self.aboutAction) + if QSettings().value("/QGEP/AdminMode", False): + self.iface.addPluginToMenu("&QGEP", self.datamodelInitToolAction) self.iface.addToolBar(self.toolbar) @@ -304,7 +312,9 @@ def unload(self): self.toolbar.deleteLater() self.iface.removePluginMenu("&QGEP", self.profileAction) + self.iface.removePluginMenu("&QGEP", self.settingsAction) self.iface.removePluginMenu("&QGEP", self.aboutAction) + self.iface.removePluginMenu("&QGEP", self.datamodelInitToolAction) QgsApplication.processingRegistry().removeProvider(self.processing_provider) @@ -425,3 +435,8 @@ def about(self): def showSettings(self): settings_dlg = QgepSettingsDialog(self.iface.mainWindow()) settings_dlg.exec_() + + def showDatamodelInitTool(self): + if not hasattr(self, "_datamodel_dlg"): + self.datamodel_dlg = QgepDatamodelInitToolDialog(self.iface.mainWindow()) + self.datamodel_dlg.show() diff --git a/qgepplugin/ui/qgepdatamodeldialog.ui b/qgepplugin/ui/qgepdatamodeldialog.ui new file mode 100644 index 00000000..dd1fc0be --- /dev/null +++ b/qgepplugin/ui/qgepdatamodeldialog.ui @@ -0,0 +1,390 @@ + + + QgepSettingsDialog + + + + 0 + 0 + 500 + 388 + + + + Datamodel tool + + + + + + Release version + + + + + + + + + + Depdencies + + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + Install + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Python requirements + + + + + + + + + + Postgres service configuration + + + + + + File location + + + + + + + font: italic; color: #888 + + + - + + + + + + + PG Config setup + + + + + + + + + + + + + + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + + + + + + + Current selection + + + + + + + font: italic; color: #888 + + + - + + + true + + + + + + + + + + Datamodel + + + + + + Current version + + + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + SRID + + + + + + + 2056 + + + + + + + Action + + + + + + + + + Initialize + + + + + + + + + + Upgrade + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + QGIS project + + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + + + + + Load + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Load QGIS project template + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Close + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Release files + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse + + + + + + + + + mBtnBoxOkCancel + accepted() + QgepSettingsDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + mBtnBoxOkCancel + rejected() + QgepSettingsDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/qgepplugin/ui/qgeppgserviceeditordialog.ui b/qgepplugin/ui/qgeppgserviceeditordialog.ui new file mode 100644 index 00000000..ef4ba6a1 --- /dev/null +++ b/qgepplugin/ui/qgeppgserviceeditordialog.ui @@ -0,0 +1,201 @@ + + + Dialog + + + + 0 + 0 + 291 + 238 + + + + PG Config editor + + + + + + Config name + + + + + + + + + + + + color: rgb(170, 0, 0); +font-weight: bold; + + + Unknown + + + + + + + + + Host + + + + + + + + + + Port + + + + + + + + + + Database + + + + + + + + + + User + + + + + + + + + + + + + + + + + + + + + Password + + + + + + + + + + + + + + + + QLineEdit::PasswordEchoOnEdit + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + WARNING ! These settings will be saved to the pg_service.conf in plain text ! Any person having access to this file will be able to access the credentials. + + + true + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/qgepplugin/ui/qgepsettingsdialog.ui b/qgepplugin/ui/qgepsettingsdialog.ui index 1b46bade..c42330ad 100644 --- a/qgepplugin/ui/qgepsettingsdialog.ui +++ b/qgepplugin/ui/qgepsettingsdialog.ui @@ -175,7 +175,17 @@ - + + + + Enables the datamodel tool. The plugin needs to be reloaded. + + + Admin mode + + + + Layout @@ -212,7 +222,7 @@ - + Logging