Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Switch project sources from GPKG to Postgre based on DBSync config #536

Closed
wants to merge 14 commits into from
39 changes: 38 additions & 1 deletion Mergin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@
from .project_settings_widget import MerginProjectConfigFactory
from .projects_manager import MerginProjectsManager
from .sync_dialog import SyncDialog
from .switch_sources_dialog import ProjectUsePostgreConfigWizard
from .configure_sync_wizard import DbSyncConfigWizard
from .remove_project_dialog import RemoveProjectDialog
from .utils import (
ServerType,
ClientError,
LoginError,
InvalidProject,
check_mergin_subdirs,
create_mergin_client,
find_qgis_files,
Expand Down Expand Up @@ -155,7 +157,13 @@ def initGui(self):
add_to_menu=True,
add_to_toolbar=None,
)

self.action_switch_sources = self.add_action(
"database-cog.svg",
text="Switch DBSync Sources",
callback=self.switch_sources,
add_to_menu=True,
add_to_toolbar=None,
)
self.enable_toolbar_actions()

self.post_login()
Expand Down Expand Up @@ -324,6 +332,35 @@ def configure_db_sync(self):
if not wizard.exec_():
return

def switch_sources(self):
project_path = QgsProject.instance().homePath()
if not project_path:
iface.messageBar().pushMessage("Mergin", "Project is not saved, please save project first", Qgis.Warning)
return

if not check_mergin_subdirs(project_path):
iface.messageBar().pushMessage(
"Mergin", "Current project is not a Mergin project. Please open a Mergin project first.", Qgis.Warning
)
return

JanCaha marked this conversation as resolved.
Show resolved Hide resolved
mp = MerginProject(project_path)
try:
project_name = mp.metadata["name"]
except InvalidProject as e:
iface.messageBar().pushMessage(
"Mergin", "Current project is not a Mergin project. Please open a Mergin project first.", Qgis.Warning
)
return

check_result = unsaved_project_check()
if check_result == UnsavedChangesStrategy.HasUnsavedChanges:
return

dialog = ProjectUsePostgreConfigWizard(self.iface)
if not dialog.exec():
return

def show_no_workspaces_dialog(self):
msg = (
"Workspace is a place to store your projects and share them with your colleagues. "
Expand Down
195 changes: 195 additions & 0 deletions Mergin/switch_sources_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import os
import yaml
JanCaha marked this conversation as resolved.
Show resolved Hide resolved
import typing
import re
import pathlib

from qgis.core import (
QgsProject,
QgsDataSourceUri,
QgsMapLayer,
Qgis,
QgsVectorLayer,
)
from qgis.gui import QgsFileWidget, QgisInterface
from qgis.PyQt import uic
from qgis.PyQt.QtWidgets import QWizard, QLineEdit, QMessageBox


base_dir = os.path.dirname(__file__)
ui_select_dbsync_page, base_select_dbsync_page = uic.loadUiType(
os.path.join(base_dir, "ui", "ui_switch_datasources_select_dbsync.ui")
)
ui_select_qgsproject_page, base_select_qgsproject_page = uic.loadUiType(
os.path.join(base_dir, "ui", "ui_switch_datasources_select_updated_project.ui")
)


DBSYNC_PAGE = 0
QGS_PROJECT_PAGE = 1


class DbSyncConfig:
def __init__(self, config_file_path: str, base_qgis_project_path: str) -> None:
self.qgis_project_path = base_qgis_project_path
self.connections = []

with open(config_file_path, mode="r", encoding="utf-8") as stream:
config = yaml.safe_load(stream)

for conn in config["connections"]:
skip_tables = conn["skip_tables"]
if skip_tables is None:
skip_tables = []
elif isinstance(skip_tables, str):
skip_tables = [skip_tables]
elif not isinstance(skip_tables, typing.List):
skip_tables = []
connection = Connection(
conn["driver"], conn["conn_info"], conn["modified"], conn["sync_file"], skip_tables
)
self.connections.append(connection)

def convert_gpkg_layers_to_postgis_sources(self, result_qgsproject_path: str):
update_project = QgsProject()
update_project.read(self.qgis_project_path)

project_layers = update_project.mapLayers()

layer: QgsMapLayer

for layer_id in project_layers:
layer = update_project.mapLayer(layer_id)

for dbsync_connection in self.connections:
if (
layer.dataProvider().name() == "ogr"
and dbsync_connection.sync_file in layer.dataProvider().dataSourceUri()
):
dbsync_connection.convert_to_postgresql_layer(layer)

update_project.write(result_qgsproject_path)


class Connection:
def __init__(
self, driver: str, db_connection_info: str, db_schema: str, sync_file: str, skip_tables: typing.List[str]
) -> None:
self.driver = driver
self.db_connection_info = db_connection_info
self.db_schema = db_schema
self.sync_file = sync_file
self.skip_tables = skip_tables

def convert_to_postgresql_layer(self, gpkg_layer: QgsVectorLayer) -> None:
layer_uri = gpkg_layer.dataProvider().dataSourceUri()

extract = re.search("\|layername=(.+)", layer_uri)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try using QgsProviderRegistry.instance().decodeUri() instead of using regexps


if extract:
layer_name = extract.group(1)

if layer_name not in self.skip_tables:
uri = QgsDataSourceUri(self.db_connection_info)
uri.setSchema(self.db_schema)
uri.setTable(layer_name)
uri.setGeometryColumn("geom") # TODO should this be hardcoded?
gpkg_layer.setDataSource(uri.uri(), gpkg_layer.name(), "postgres")

def convert_to_gpkg_layer(self, postgresql_layer: QgsVectorLayer, gpkg_folder: str) -> None:
gpkg = QgsVectorLayer(f"{gpkg_folder}/{self.sync_file}", "temp", "ogr")
gpkg_layers = [x.split("!!::!!")[1] for x in gpkg.dataProvider().subLayers()]

table_name = postgresql_layer.dataProvider().uri().table()

if table_name in gpkg_layers:
uri = f"{gpkg_folder}/{self.sync_file}|layername={table_name}"

postgresql_layer.setDataSource(uri, postgresql_layer.name(), "ogr")
JanCaha marked this conversation as resolved.
Show resolved Hide resolved


class DBSyncConfigSelectionPage(ui_select_dbsync_page, base_select_dbsync_page):
"""Initial wizard page with selection od dbsync file selector."""

select_db_sync_config: QgsFileWidget
hidden_dbsync_config_file: QLineEdit

def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(self)
self.parent = parent

self.hidden_dbsync_config_file.hide()

self.registerField("db_sync_file*", self.hidden_dbsync_config_file)

self.select_db_sync_config.setFilter("DBSync configuration files (*.yaml *.YAML)")
self.select_db_sync_config.fileChanged.connect(self.db_sync_config)

def db_sync_config(self, path: str) -> None:
self.hidden_dbsync_config_file.setText(path)

def nextId(self):
return QGS_PROJECT_PAGE


class QgsProjectSelectionPage(ui_select_qgsproject_page, base_select_qgsproject_page):
"""Wizard page with selection od QgsProject file selector."""

select_qgis_project_name: QgsFileWidget
hidden_new_qgis_project_file: QLineEdit

def __init__(self, parent=None):
super().__init__(parent)
self.setupUi(self)
self.parent = parent

self.hidden_new_qgis_project_file.hide()

self.registerField("qgis_project*", self.hidden_new_qgis_project_file)

self.select_qgis_project_name.setFilter("QGIS files (*.qgz *.qgs *.QGZ *.QGS)")
self.select_qgis_project_name.setStorageMode(QgsFileWidget.StorageMode.SaveFile)
self.select_qgis_project_name.fileChanged.connect(self.qgis_project)

def qgis_project(self, path: str) -> None:
folders = [x.name for x in pathlib.Path(path).parent.iterdir() if x.is_dir()]

if ".mergin" in folders:
QMessageBox.critical(
None,
"Bad project location",
"The updated project should not be saved within Mergin directory. Please select different location.",
)
self.select_qgis_project_name.lineEdit().clear()
return

self.hidden_new_qgis_project_file.setText(path)


class ProjectUsePostgreConfigWizard(QWizard):
"""Wizard for changing project layer sources from GPKG to PostgreSQL."""

def __init__(self, iface: QgisInterface, parent=None):
"""Create a wizard"""
super().__init__(parent)

self.iface = iface
self.setWindowTitle("Create project with layers from PostgreSQL")

self.connections: typing.List[Connection] = []

self.qgis_project = QgsProject.instance()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not store reference to QgsProject.instance() to self.qgis_project - simply use QgsProject.instance() where needed, it will be easier to read the code


self.start_page = DBSyncConfigSelectionPage(self)
self.setPage(DBSYNC_PAGE, self.start_page)

self.qgsproject_page = QgsProjectSelectionPage(self)
self.setPage(QGS_PROJECT_PAGE, self.qgsproject_page)

def accept(self) -> None:
dbsync = DbSyncConfig(self.start_page.field("db_sync_file"), self.qgis_project.fileName())

dbsync.convert_gpkg_layers_to_postgis_sources(self.qgsproject_page.field("qgis_project"))

return super().accept()
68 changes: 68 additions & 0 deletions Mergin/ui/ui_switch_datasources_select_dbsync.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>WizardPage</class>
<widget class="QWizardPage" name="WizardPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>249</height>
</rect>
</property>
<property name="windowTitle">
<string>WizardPage</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label">
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Layers from the current QGIS project that are stored in the synchronization file specified in the DBSync file will be updated to their corresponding layers that are stored in the updated schema of the PostgreSQL.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Select DBSync config file:</string>
</property>
</widget>
</item>
<item>
<widget class="QgsFileWidget" name="select_db_sync_config"/>
</item>
<item>
<widget class="QLineEdit" name="hidden_dbsync_config_file"/>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>QgsFileWidget</class>
<extends>QWidget</extends>
<header>qgsfilewidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
55 changes: 55 additions & 0 deletions Mergin/ui/ui_switch_datasources_select_updated_project.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>WizardPage</class>
<widget class="QWizardPage" name="WizardPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>249</height>
</rect>
</property>
<property name="windowTitle">
<string>WizardPage</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>QGIS Project File: </string>
</property>
</widget>
</item>
<item>
<widget class="QgsFileWidget" name="select_qgis_project_name"/>
</item>
<item>
<widget class="QLineEdit" name="hidden_new_qgis_project_file"/>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>QgsFileWidget</class>
<extends>QWidget</extends>
<header>qgsfilewidget.h</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>