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

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
41 changes: 40 additions & 1 deletion Mergin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
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 (
Expand Down Expand Up @@ -155,7 +156,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 +331,38 @@ 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

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

if check_mergin_subdirs(dialog.new_project_parent_folder()):
iface.messageBar().pushMessage(
"Mergin",
"The updated project should not be saved within Mergin directory. Please move it elsewhere.",
Qgis.Warning,
)
JanCaha marked this conversation as resolved.
Show resolved Hide resolved

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

import psycopg2
JanCaha marked this conversation as resolved.
Show resolved Hide resolved

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


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


@dataclass
JanCaha marked this conversation as resolved.
Show resolved Hide resolved
class Connection:
driver: str
db_connection_info: str
db_schema: str
sync_file: str

valid: bool = False
db_tables: typing.List[str] = field(init=False)

def __post_init__(self):
try:
conn = psycopg2.connect(self.db_connection_info)
except Exception as e:
return
cur = conn.cursor()
cur.execute(f"SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = '{self.db_schema}'")
self.db_tables = [x[0] for x in cur.fetchall()]
JanCaha marked this conversation as resolved.
Show resolved Hide resolved
self.valid = True

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 in self.db_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."""

selectDbSyncConfig: QgsFileWidget
ldbsync_config_file: QLineEdit

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

self.ldbsync_config_file.hide()

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

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

def db_sync_config(self, path: str) -> None:
self.ldbsync_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."""

selectQgisProject: QgsFileWidget
lqgsproject_file: QLineEdit
JanCaha marked this conversation as resolved.
Show resolved Hide resolved

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

self.lqgsproject_file.hide()

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

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

def qgis_project(self, path: str) -> None:
self.lqgsproject_file.setText(path)


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

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

self.iface = iface
self.setWindowTitle("Create project with layers from Postgre Database")
JanCaha marked this conversation as resolved.
Show resolved Hide resolved

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:
self.read_connections(self.start_page.field("db_sync_file"))

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

return super().accept()

def read_connections(self, path: str) -> None:
if path:
with open(path, mode="r", encoding="utf-8") as stream:
config = yaml.safe_load(stream)

self.connections = []
invalid_connections_info = []

for conn in config["connections"]:
connection = Connection(conn["driver"], conn["conn_info"], conn["modified"], conn["sync_file"])
if connection.valid:
self.connections.append(connection)
else:
invalid_connections_info.append(conn["conn_info"])

if invalid_connections_info:
self.iface.messageBar().pushMessage(
"Mergin",
f"Cannot connect to following databases: {'; '.join(invalid_connections_info)}.",
level=Qgis.Critical,
duration=0,
)
else:
self.connections = []

def new_project_parent_folder(self) -> str:
return os.path.dirname(self.qgsproject_page.field("qgis_project"))

def convert_gpkg_layers_to_postgis_sources(self, result_qgsproject_path: str):
Copy link
Contributor

Choose a reason for hiding this comment

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

I would prefer to have all the conversion code (+Connection class) outside of the GUI code, in a separate python file, so we can possibly also run auto-tests checking the conversion functionality...

update_project = QgsProject()
update_project.read(self.qgis_project.fileName())
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe we don't need to read the full project - we don't need to resolve the layers, it will be much faster to read it


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()
Copy link
Contributor

Choose a reason for hiding this comment

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

shouldn't we do a better check for the URI match? e.g. if the sync_file is "data.gpkg" and there's also "backup/data.gpkg" file in the project, or maybe the QGIS project is also referencing a file outside of the MM project directory?

):
dbsync_connection.convert_to_postgresql_layer(layer)

update_project.write(result_qgsproject_path)
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 database.&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="selectDbSyncConfig"/>
</item>
<item>
<widget class="QLineEdit" name="ldbsync_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>
Loading