diff --git a/Mergin/utils.py b/Mergin/utils.py index 5f4e3c74..c96e4a79 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -944,24 +944,31 @@ def unhandled_exception_message(error_details, dialog_title, error_text, mm_clie 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)) + project = QgsProject.instance() + # these variables are stored in the project file, so writing them marks the project as modified; + was_dirty = project.isDirty() + QgsExpressionContextUtils.setProjectVariable(project, "mergin_project_name", project_name) + QgsExpressionContextUtils.setProjectVariable(project, "mergin_project_full_name", project_full_name) + QgsExpressionContextUtils.setProjectVariable(project, "mergin_project_version", int_version(version)) + QgsExpressionContextUtils.setProjectVariable(project, "mm_project_name", project_name) + QgsExpressionContextUtils.setProjectVariable(project, "mm_project_full_name", project_full_name) + QgsExpressionContextUtils.setProjectVariable(project, "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 "") + QgsExpressionContextUtils.setProjectVariable(project, "mm_project_role", role or "") + project.setDirty(was_dirty) def remove_project_variables(): - QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mergin_project_name") - QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mergin_project_full_name") - QgsExpressionContextUtils.removeProjectVariable(QgsProject.instance(), "mergin_project_version") - 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") + project = QgsProject.instance() + was_dirty = project.isDirty() + QgsExpressionContextUtils.removeProjectVariable(project, "mergin_project_name") + QgsExpressionContextUtils.removeProjectVariable(project, "mergin_project_full_name") + QgsExpressionContextUtils.removeProjectVariable(project, "mergin_project_version") + QgsExpressionContextUtils.removeProjectVariable(project, "mm_project_name") + QgsExpressionContextUtils.removeProjectVariable(project, "mm_project_full_name") + QgsExpressionContextUtils.removeProjectVariable(project, "mm_project_version") + QgsExpressionContextUtils.removeProjectVariable(project, "mm_project_role") + project.setDirty(was_dirty) def pretty_summary(summary): diff --git a/tests/conftest.py b/tests/conftest.py index f24af13d..f01f2eb3 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": "Lutra Consulting"}, + "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..ca423522 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,10 @@ create_tracking_layer, get_datum_shift_grids, is_valid_name, + remove_project_variables, same_schema, + set_qgis_project_mergin_variables, + write_project_variables, ) @@ -229,3 +234,66 @@ 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_writing_variables_keeps_a_clean_project_clean(project_dir: Path): + """Variables live in the project file, so writing them must not leave the user unsaved changes.""" + proj = QgsProject.instance() + proj.setDirty(False) + + write_project_variables("survey", "Lutra Consulting/survey", "v3", "editor") + + assert not proj.isDirty() + scope = QgsExpressionContextUtils.projectScope(proj) + assert scope.variable("mm_project_version") == 3 + assert scope.variable("mm_project_role") == "editor" + + # a later version really does reach expressions, it is only the dirty flag we suppress + write_project_variables("survey", "Lutra Consulting/survey", "v4", "editor") + assert QgsExpressionContextUtils.projectScope(proj).variable("mm_project_version") == 4 + assert not proj.isDirty() + + remove_project_variables() + assert not proj.isDirty() + + +def test_writing_variables_keeps_unsaved_user_changes_flagged(project_dir: Path): + """The user's own unsaved work must still prompt them to save.""" + proj = QgsProject.instance() + proj.setDirty(False) + proj.setTitle("edited by the user") + assert proj.isDirty() + + write_project_variables("survey", "Lutra Consulting/survey", "v3", "editor") + + assert proj.isDirty() + assert proj.title() == "edited by the user" + + remove_project_variables() + assert proj.isDirty() + + +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") == "Lutra Consulting/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()