From 2c8ceec89e883bd0fd9c83136c5ff2e48ed7e1a8 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 16 Sep 2026 15:07:14 +0200 Subject: [PATCH 1/3] Add @mm_project_role QGIS variable --- Mergin/projects_manager.py | 14 +++++++++++++- Mergin/utils.py | 7 +++++-- tests/conftest.py | 18 ++++++++++++++++++ tests/test_utils.py | 30 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Mergin/projects_manager.py b/Mergin/projects_manager.py index c4243592..8f4e4b34 100644 --- a/Mergin/projects_manager.py +++ b/Mergin/projects_manager.py @@ -28,8 +28,10 @@ LoginError, find_qgis_files, login_error_message, + mergin_project_local_path, same_dir, send_logs, + set_qgis_project_mergin_variables, storage_limit_fail, unhandled_exception_message, unsaved_project_check, @@ -220,7 +222,7 @@ def create_project(self, project_name, project_dir, is_public, namespace): project_dir == QgsProject.instance().absolutePath() or project_dir + "/" in QgsProject.instance().absolutePath() ): - write_project_variables(project_name, full_project_name, "v1") + write_project_variables(project_name, full_project_name, "v1", mp.project_role()) QMessageBox.information( None, @@ -231,6 +233,11 @@ def create_project(self, project_name, project_dir, is_public, namespace): return True + def refresh_project_variables(self, project_dir): + """Re-read the Mergin Maps variables from the project metadata, if that project is the open one.""" + if same_dir(project_dir, mergin_project_local_path()): + set_qgis_project_mergin_variables(project_dir) + def project_status(self, project_dir): if project_dir is None: return @@ -254,6 +261,7 @@ def project_status(self, project_dir): if qgis_proj_filename in find_qgis_files(project_dir): AuthSync().export_auth(self.mc) pull_changes, push_changes, push_changes_summary = self.mc.project_status(project_dir) + self.refresh_project_variables(project_dir) dlg = ProjectStatusDialog( pull_changes, push_changes, @@ -399,6 +407,9 @@ def sync_project(self, project_dir, project_name=None): QMessageBox.critical(None, "Project syncing", msg, QMessageBox.StandardButton.Close) return + # a role change creates no project version, so it only arrives with the status we just fetched + self.refresh_project_variables(project_dir) + if not sum(len(v) for v in list(pull_changes.values()) + list(push_changes.values())): QMessageBox.information( None, @@ -510,6 +521,7 @@ def sync_project(self, project_dir, project_name=None): _, has_push_changes = get_push_changes_batch(self.mc, project_dir) error_retries_attempts = 0 if not has_push_changes: + self.refresh_project_variables(project_dir) # TODO: report success only when we have actually done anything msg = "Mergin Maps project {} synchronised successfully".format(project_name) QMessageBox.information(None, "Project sync", msg, QMessageBox.StandardButton.Close) diff --git a/Mergin/utils.py b/Mergin/utils.py index a5ee8123..36b1c43b 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -943,13 +943,15 @@ def unhandled_exception_message(error_details, dialog_title, error_text, mm_clie box.exec() -def write_project_variables(project_name, project_full_name, version): +def write_project_variables(project_name, project_full_name, version, role=""): QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mergin_project_name", project_name) QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mergin_project_full_name", project_full_name) QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mergin_project_version", int_version(version)) QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mm_project_name", project_name) QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mm_project_full_name", project_full_name) QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mm_project_version", int_version(version)) + # projects downloaded with an older client have no role in their metadata + QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "mm_project_role", role or "") def remove_project_variables(): @@ -959,6 +961,7 @@ def remove_project_variables(): QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mm_project_name") QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mm_project_full_name") QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mm_project_version") + QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mm_project_role") def pretty_summary(summary): @@ -1022,7 +1025,7 @@ def set_qgis_project_mergin_variables(project_dir): try: mp = MerginProject(project_dir) - write_project_variables(mp.project_name(), mp.project_full_name(), mp.version()) + write_project_variables(mp.project_name(), mp.project_full_name(), mp.version(), mp.project_role()) except InvalidProject: remove_project_variables() diff --git a/tests/conftest.py b/tests/conftest.py index f24af13d..ff37a630 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,3 +71,21 @@ def layer_field_filter(test_data_path: Path) -> QgsVectorLayer: layer = QgsVectorLayer(str(test_data_path / "data_field_filter.gpkg"), "field filter layer", "ogr") assert layer.isValid() return layer + + +@pytest.fixture +def mergin_project_dir(tmp_path: Path) -> Path: + """Fixture for a downloaded Mergin Maps project directory with its metadata.""" + metadata = { + "id": "b2e0b0b0-0000-4000-8000-000000000000", + "name": "survey", + "workspace": {"id": 1, "name": "acme"}, + "version": "v3", + "role": "editor", + "files": [], + } + meta_dir = tmp_path / ".mergin" + meta_dir.mkdir() + with open(meta_dir / "mergin.json", "w") as f: + json.dump(metadata, f) + return tmp_path diff --git a/tests/test_utils.py b/tests/test_utils.py index ce09e809..aaa7fba2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,7 @@ import copy +import json import tempfile from pathlib import Path from typing import Dict @@ -13,6 +14,7 @@ QgsCoordinateReferenceSystem, QgsCoordinateTransformContext, QgsDatumTransform, + QgsExpressionContextUtils, QgsProject, QgsSymbolLayer, QgsVectorLayer, @@ -25,7 +27,9 @@ create_tracking_layer, get_datum_shift_grids, is_valid_name, + remove_project_variables, same_schema, + set_qgis_project_mergin_variables, ) @@ -229,3 +233,29 @@ def test_create_map_sketches_layer(): sl = layer.renderer().symbol().symbolLayer(0) assert sl.dataDefinedProperties().property(QgsSymbolLayer.PropertyStrokeColor).expressionString() == '"color"' assert sl.dataDefinedProperties().property(QgsSymbolLayer.PropertyStrokeWidth).expressionString() == '"width"' + + +def project_variable(name: str): + return QgsExpressionContextUtils.projectScope(QgsProject.instance()).variable(name) + + +def test_project_role_variable(mergin_project_dir: Path): + set_qgis_project_mergin_variables(str(mergin_project_dir)) + assert project_variable("mm_project_full_name") == "acme/survey" + assert project_variable("mm_project_role") == "editor" + + remove_project_variables() + assert project_variable("mm_project_role") is None + + +def test_project_role_variable_missing_in_metadata(mergin_project_dir: Path): + metadata_path = mergin_project_dir / ".mergin" / "mergin.json" + with open(metadata_path) as f: + metadata = json.load(f) + del metadata["role"] + with open(metadata_path, "w") as f: + json.dump(metadata, f) + + set_qgis_project_mergin_variables(str(mergin_project_dir)) + assert project_variable("mm_project_role") == "" + remove_project_variables() From 28166451d7c5013b10b025de516f7c5d564df5e7 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 16 Sep 2026 15:08:14 +0200 Subject: [PATCH 2/3] Refresh project role on sign in --- Mergin/plugin.py | 4 ++++ Mergin/utils.py | 15 +++++++++++++++ tests/conftest.py | 18 ------------------ tests/test_utils.py | 30 ------------------------------ 4 files changed, 19 insertions(+), 48 deletions(-) diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 3b473f84..a2b1c8be 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -43,6 +43,7 @@ icon_path, mm_symbol_path, mergin_project_local_path, + refresh_project_role, remove_project_variables, set_qgis_project_mergin_variables, unsaved_project_check, @@ -247,6 +248,9 @@ def create_manager(self): if self.mc is not None: self.choose_active_workspace() self.manager = MerginProjectsManager(self) + if self.mergin_proj_dir is not None: + refresh_project_role(self.mc, self.mergin_proj_dir) + set_qgis_project_mergin_variables(self.mergin_proj_dir) else: error = "Configure the Mergin Maps plugin \nto access your projects" except (URLError, ClientError, LoginError): diff --git a/Mergin/utils.py b/Mergin/utils.py index 36b1c43b..5f4e3c74 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1019,6 +1019,21 @@ def get_local_mergin_projects_info(workspace=None): return local_projects_info +def refresh_project_role(mc, project_dir): + """Ask the server for the user's current role on the project and store it in the project metadata.""" + try: + mp = MerginProject(project_dir) + try: + role = mc.project_info_v2(mp.project_id()).role + except (NotImplementedError, ClientError): + # servers below 2025.8.2 have no v2 project info, and old projects have no id in their metadata + role = mc.project_info(mp.project_full_name())["role"] + mp.update_project_role(role) + except (InvalidProject, ClientError, URLError): + # keep whatever the metadata holds, the variable has to stay usable offline + pass + + def set_qgis_project_mergin_variables(project_dir): """Check if QGIS project project_dir is a local Mergin Maps project and set QGIS project variables for Mergin Maps.""" diff --git a/tests/conftest.py b/tests/conftest.py index ff37a630..f24af13d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,21 +71,3 @@ def layer_field_filter(test_data_path: Path) -> QgsVectorLayer: layer = QgsVectorLayer(str(test_data_path / "data_field_filter.gpkg"), "field filter layer", "ogr") assert layer.isValid() return layer - - -@pytest.fixture -def mergin_project_dir(tmp_path: Path) -> Path: - """Fixture for a downloaded Mergin Maps project directory with its metadata.""" - metadata = { - "id": "b2e0b0b0-0000-4000-8000-000000000000", - "name": "survey", - "workspace": {"id": 1, "name": "acme"}, - "version": "v3", - "role": "editor", - "files": [], - } - meta_dir = tmp_path / ".mergin" - meta_dir.mkdir() - with open(meta_dir / "mergin.json", "w") as f: - json.dump(metadata, f) - return tmp_path diff --git a/tests/test_utils.py b/tests/test_utils.py index aaa7fba2..ce09e809 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -5,7 +5,6 @@ import copy -import json import tempfile from pathlib import Path from typing import Dict @@ -14,7 +13,6 @@ QgsCoordinateReferenceSystem, QgsCoordinateTransformContext, QgsDatumTransform, - QgsExpressionContextUtils, QgsProject, QgsSymbolLayer, QgsVectorLayer, @@ -27,9 +25,7 @@ create_tracking_layer, get_datum_shift_grids, is_valid_name, - remove_project_variables, same_schema, - set_qgis_project_mergin_variables, ) @@ -233,29 +229,3 @@ def test_create_map_sketches_layer(): sl = layer.renderer().symbol().symbolLayer(0) assert sl.dataDefinedProperties().property(QgsSymbolLayer.PropertyStrokeColor).expressionString() == '"color"' assert sl.dataDefinedProperties().property(QgsSymbolLayer.PropertyStrokeWidth).expressionString() == '"width"' - - -def project_variable(name: str): - return QgsExpressionContextUtils.projectScope(QgsProject.instance()).variable(name) - - -def test_project_role_variable(mergin_project_dir: Path): - set_qgis_project_mergin_variables(str(mergin_project_dir)) - assert project_variable("mm_project_full_name") == "acme/survey" - assert project_variable("mm_project_role") == "editor" - - remove_project_variables() - assert project_variable("mm_project_role") is None - - -def test_project_role_variable_missing_in_metadata(mergin_project_dir: Path): - metadata_path = mergin_project_dir / ".mergin" / "mergin.json" - with open(metadata_path) as f: - metadata = json.load(f) - del metadata["role"] - with open(metadata_path, "w") as f: - json.dump(metadata, f) - - set_qgis_project_mergin_variables(str(mergin_project_dir)) - assert project_variable("mm_project_role") == "" - remove_project_variables() From badef90da5decab80764984a021048a1aef53edd Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 17 Sep 2026 12:30:14 +0200 Subject: [PATCH 3/3] refresh the role on project open --- Mergin/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mergin/plugin.py b/Mergin/plugin.py index a2b1c8be..28100079 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -543,6 +543,8 @@ def on_qgis_project_changed(self): self.mergin_proj_dir = mergin_project_local_path() if self.mergin_proj_dir is not None: self.enable_toolbar_actions() + if self.mc is not None: + refresh_project_role(self.mc, self.mergin_proj_dir) set_qgis_project_mergin_variables(self.mergin_proj_dir) # re-render Browser items so the opened-project indicator follows the active QGIS project. if self.has_browser_item():