diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 3b473f84..7c4ce600 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -43,6 +43,8 @@ icon_path, mm_symbol_path, mergin_project_local_path, + refresh_project_role, + remove_project_role_variable, remove_project_variables, set_qgis_project_mergin_variables, unsaved_project_check, @@ -247,6 +249,8 @@ 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) else: error = "Configure the Mergin Maps plugin \nto access your projects" except (URLError, ClientError, LoginError): @@ -540,6 +544,11 @@ def on_qgis_project_changed(self): if self.mergin_proj_dir is not None: self.enable_toolbar_actions() set_qgis_project_mergin_variables(self.mergin_proj_dir) + if self.mc is not None: + refresh_project_role(self.mc, self.mergin_proj_dir) + else: + # a global variable outlives the project it came from, unlike the project ones + remove_project_role_variable() # re-render Browser items so the opened-project indicator follows the active QGIS project. if self.has_browser_item(): self.data_item_provider.root_item.reload_local() @@ -582,6 +591,7 @@ def unload(self): QgsExpressionContextUtils.removeGlobalVariable("mm_full_name") QgsExpressionContextUtils.removeGlobalVariable("mergin_user_email") QgsExpressionContextUtils.removeGlobalVariable("mm_user_email") + remove_project_role_variable() QgsApplication.instance().dataItemProviderRegistry().removeProvider(self.data_item_provider) self.data_item_provider = None # unload pygeodiff to avoid .pyd to be write-protected and thus impossible to delete on Windows diff --git a/Mergin/projects_manager.py b/Mergin/projects_manager.py index c4243592..15e5aadd 100644 --- a/Mergin/projects_manager.py +++ b/Mergin/projects_manager.py @@ -28,6 +28,7 @@ LoginError, find_qgis_files, login_error_message, + mergin_project_local_path, same_dir, send_logs, storage_limit_fail, @@ -35,6 +36,7 @@ unsaved_project_check, UnsavedChangesStrategy, write_project_variables, + write_project_role_variable, bytes_to_human_size, is_file_changed, get_push_changes_batch, @@ -221,6 +223,7 @@ def create_project(self, project_name, project_dir, is_public, namespace): or project_dir + "/" in QgsProject.instance().absolutePath() ): write_project_variables(project_name, full_project_name, "v1") + write_project_role_variable(mp.project_role()) QMessageBox.information( None, @@ -231,6 +234,15 @@ def create_project(self, project_name, project_dir, is_public, namespace): return True + def refresh_project_role_variable(self, project_dir): + """Re-read the role the client just refreshed, unless this is not the project open in QGIS. + + Both sync and the status dialog can be started from the Browser panel for any downloaded + project, while the variable only ever describes the open one. + """ + if same_dir(project_dir, mergin_project_local_path()): + write_project_role_variable(MerginProject(project_dir).project_role()) + def project_status(self, project_dir): if project_dir is None: return @@ -254,6 +266,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_role_variable(project_dir) dlg = ProjectStatusDialog( pull_changes, push_changes, @@ -399,6 +412,8 @@ def sync_project(self, project_dir, project_name=None): QMessageBox.critical(None, "Project syncing", msg, QMessageBox.StandardButton.Close) return + self.refresh_project_role_variable(project_dir) + if not sum(len(v) for v in list(pull_changes.values()) + list(push_changes.values())): QMessageBox.information( None, @@ -510,6 +525,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_role_variable(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..c1ac47cb 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -961,6 +961,14 @@ def remove_project_variables(): QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mm_project_version") +def write_project_role_variable(role): + QgsExpressionContextUtils.setGlobalVariable("mm_project_role", role or "") + + +def remove_project_role_variable(): + QgsExpressionContextUtils.removeGlobalVariable("mm_project_role") + + def pretty_summary(summary): msg = "" for k, v in summary.items(): @@ -1016,6 +1024,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.""" + 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"] + write_project_role_variable(role) + except (InvalidProject, ClientError, URLError): + # keep the role already read from the metadata, 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.""" @@ -1023,8 +1046,10 @@ def set_qgis_project_mergin_variables(project_dir): mp = MerginProject(project_dir) write_project_variables(mp.project_name(), mp.project_full_name(), mp.version()) + write_project_role_variable(mp.project_role()) except InvalidProject: remove_project_variables() + remove_project_role_variable() def mergin_project_local_path(project_name=None): 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..db0cf9d6 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_role_variable, 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 test_project_role_variable_is_global(mergin_project_dir: Path): + """The role belongs to the signed in user, so it must not travel in the shared project file.""" + set_qgis_project_mergin_variables(str(mergin_project_dir)) + + assert QgsExpressionContextUtils.globalScope().variable("mm_project_role") == "editor" + project_scope = QgsExpressionContextUtils.projectScope(QgsProject.instance()) + assert project_scope.variable("mm_project_full_name") == "acme/survey" + assert project_scope.variable("mm_project_role") is None + + remove_project_role_variable() + assert QgsExpressionContextUtils.globalScope().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 QgsExpressionContextUtils.globalScope().variable("mm_project_role") == "" + remove_project_role_variable()