Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Mergin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Mergin/projects_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@
LoginError,
find_qgis_files,
login_error_message,
mergin_project_local_path,
same_dir,
send_logs,
storage_limit_fail,
unhandled_exception_message,
unsaved_project_check,
UnsavedChangesStrategy,
write_project_variables,
write_project_role_variable,
bytes_to_human_size,
is_file_changed,
get_push_changes_batch,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions Mergin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -1016,15 +1024,32 @@ 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."""

try:
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):
Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@


import copy
import json
import tempfile
from pathlib import Path
from typing import Dict
Expand All @@ -13,6 +14,7 @@
QgsCoordinateReferenceSystem,
QgsCoordinateTransformContext,
QgsDatumTransform,
QgsExpressionContextUtils,
QgsProject,
QgsSymbolLayer,
QgsVectorLayer,
Expand All @@ -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,
)


Expand Down Expand Up @@ -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()
Loading