diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 454acab1..de2b0420 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,6 +1,6 @@ name: Build Mergin Plugin Packages env: - MERGIN_CLIENT_VER: "0.9.0" + MERGIN_CLIENT_VER: "0.9.2" GEODIFF_VER: "2.0.2" PYTHON_VER: "38" PLUGIN_NAME: Mergin diff --git a/Mergin/project_status_dialog.py b/Mergin/project_status_dialog.py index ed143cb8..421584eb 100644 --- a/Mergin/project_status_dialog.py +++ b/Mergin/project_status_dialog.py @@ -1,5 +1,6 @@ import os from itertools import groupby +from urllib.parse import urlparse, parse_qs from qgis.PyQt import uic from qgis.PyQt.QtWidgets import ( @@ -16,7 +17,7 @@ from qgis.core import Qgis, QgsApplication, QgsProject from qgis.utils import OverrideCursor from .diff_dialog import DiffViewerDialog -from .validation import MultipleLayersWarning, warning_display_string, MerginProjectValidator +from .validation import MultipleLayersWarning, warning_display_string, MerginProjectValidator, SingleLayerWarning from .utils import is_versioned_file, icon_path, unsaved_project_check, UnsavedChangesStrategy from .repair import fix_datum_shift_grids @@ -43,10 +44,14 @@ def __init__( push_changes_summary, has_write_permissions, mergin_project=None, + project_permission=None, parent=None, ): QDialog.__init__(self, parent) self.ui = uic.loadUi(ui_file, self) + self.project_permission = project_permission + self.push_changes = push_changes + self.file_to_reset = None with OverrideCursor(Qt.WaitCursor): QgsGui.instance().enableAutoGeometryRestore(self) @@ -175,15 +180,16 @@ def show_validation_results(self, results): html = [] # separate MultipleLayersWarning and SingleLayerWarning items - groups = dict() - for k, v in groupby(results, key=lambda x: "multi" if isinstance(x, MultipleLayersWarning) else "single"): - groups[k] = list(v) + groups = { + "single": [item for item in results if isinstance(item, SingleLayerWarning)], + "multi": [item for item in results if isinstance(item, MultipleLayersWarning)], + } # first add MultipleLayersWarnings. They are displayed using warning # string as a title and list of affected layers/items if "multi" in groups: for w in groups["multi"]: - issue = warning_display_string(w.id) + issue = warning_display_string(w.id, w.url) html.append(f"

{issue}

") if w.items: items = [] @@ -202,7 +208,7 @@ def show_validation_results(self, results): html.append(f"

{map_layers[lid].name()}

") items = [] for w in layers[lid]: - items.append(f"
  • {warning_display_string(w.warning)}
  • ") + items.append(f"
  • {warning_display_string(w.warning, w.url)}
  • ") html.append(f"") self.txtWarnings.setHtml("".join(html)) @@ -223,17 +229,20 @@ def show_changes(self): dlg_diff_viewer.exec_() def link_clicked(self, url): - function_name = url.toString() - if function_name == "#fix_datum_shift_grids": + parsed_url = urlparse(url.toString()) + if parsed_url.path == "fix_datum_shift_grids": msg = fix_datum_shift_grids(self.mp) if msg is not None: self.ui.messageBar.pushMessage("Mergin", f"Failed to fix issue: {msg}", Qgis.Warning) return + if parsed_url.path == "reset_file": + query_parameters = parse_qs(parsed_url.query) + self.reset_local_changes(query_parameters["layer"][0]) self.validate_project() def validate_project(self): - validator = MerginProjectValidator(self.mp) + validator = MerginProjectValidator(self.mp, self.push_changes, self.project_permission) results = validator.run_checks() if not results: self.ui.lblWarnings.hide() @@ -243,11 +252,16 @@ def validate_project(self): self.show_validation_results(results) self.btn_sync.setStyleSheet("background-color: #ffc800") - def reset_local_changes(self): + def reset_local_changes(self, file_to_reset=None): + if file_to_reset: + self.file_to_reset = file_to_reset + text = f"Local changes to file '{file_to_reset}' will be discarded. Do you want to proceed?" + else: + text = "All changes in your project directory will be reverted. Do you want to proceed?" btn_reply = QMessageBox.question( None, "Reset changes", - "All changes in your project directory will be reverted. Do you want to proceed?", + text, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) diff --git a/Mergin/projects_manager.py b/Mergin/projects_manager.py index 0678effa..83cd2b7f 100644 --- a/Mergin/projects_manager.py +++ b/Mergin/projects_manager.py @@ -27,7 +27,6 @@ from .mergin.merginproject import MerginProject from .project_status_dialog import ProjectStatusDialog -from .validation import MerginProjectValidator class MerginProjectsManager(object): @@ -183,16 +182,17 @@ def project_status(self, project_dir): push_changes_summary, self.mc.has_writing_permissions(project_name), mp, + self.mc.project_info(project_name)["role"], ) # Sync button in the status dialog returns QDialog.Accepted - # and Close button retuns QDialog::Rejected, so it dialog was + # and Close button returns QDialog::Rejected, so if dialog was # accepted we start sync return_value = dlg.exec_() if return_value == ProjectStatusDialog.Accepted: self.sync_project(project_dir) elif return_value == ProjectStatusDialog.RESET_CHANGES: - self.reset_local_changes(project_dir) + self.reset_local_changes(project_dir, dlg.file_to_reset) except (URLError, ClientError, InvalidProject) as e: msg = f"Failed to get status for project {project_name}:\n\n{str(e)}" @@ -229,7 +229,7 @@ def check_project_server(self, project_dir, inform_user=True): QMessageBox.critical(None, "Mergin Maps", info) return False - def reset_local_changes(self, project_dir: str): + def reset_local_changes(self, project_dir: str, files_to_reset=None): if not project_dir: return if not self.check_project_server(project_dir): @@ -241,7 +241,13 @@ def reset_local_changes(self, project_dir: str): QgsProject.instance().clear() try: - self.mc.reset_local_changes(project_dir) + self.mc.reset_local_changes(project_dir, files_to_reset) + if files_to_reset: + msg = f"File {files_to_reset} was successfully reset" + else: + msg = "Project local changes were successfully reset" + QMessageBox.information(None, "Project reset local changes", msg, QMessageBox.Close) + except Exception as e: msg = f"Failed to reset local changes:\n\n{str(e)}" QMessageBox.critical(None, "Project reset local changes", msg, QMessageBox.Close) diff --git a/Mergin/utils.py b/Mergin/utils.py index 0e739942..603643f6 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1491,3 +1491,16 @@ def set_tracking_layer_flags(layer): """ layer.setReadOnly(False) layer.setFlags(QgsMapLayer.LayerFlag(QgsMapLayer.Identifiable + QgsMapLayer.Searchable + QgsMapLayer.Removable)) + + +def get_layer_by_path(path): + """ + Returns layer object for project layer that matches the path + """ + layers = QgsProject.instance().mapLayers() + for layer in layers.values(): + _, layer_path = os.path.split(layer.source()) + # file path may contain layer name next to the file name (e.g. 'Survey_lines.gpkg|layername=lines') + safe_file_path = layer_path.split("|") + if safe_file_path[0] == path: + return layer diff --git a/Mergin/validation.py b/Mergin/validation.py index 32bbb705..46361399 100644 --- a/Mergin/validation.py +++ b/Mergin/validation.py @@ -9,7 +9,6 @@ QgsVectorDataProvider, QgsExpression, QgsRenderContext, - QgsProviderRegistry, ) from .help import MerginHelp @@ -22,6 +21,8 @@ project_grids_directory, QGIS_DB_PROVIDERS, QGIS_NET_PROVIDERS, + is_versioned_file, + get_layer_by_path, ) INVALID_CHARS = re.compile('[\\\/\(\)\[\]\{\}"\n\r]') @@ -52,6 +53,10 @@ class Warning(Enum): MERGIN_SNAPPING_NOT_ENABLED = 21 MISSING_DATUM_SHIFT_GRID = 22 SVG_NOT_EMBEDDED = 23 + EDITOR_PROJECT_FILE_CHANGE = 24 + EDITOR_NON_DIFFABLE_CHANGE = 25 + EDITOR_JSON_CONFIG_CHANGE = 26 + EDITOR_DIFFBASED_FILE_REMOVED = 27 class MultipleLayersWarning: @@ -62,23 +67,25 @@ class MultipleLayersWarning: layers. """ - def __init__(self, warning_id): + def __init__(self, warning_id, url=""): self.id = warning_id self.items = list() + self.url = url class SingleLayerWarning: """Class for warning which is associated with single layer.""" - def __init__(self, layer_id, warning): + def __init__(self, layer_id, warning, url=None): self.layer_id = layer_id self.warning = warning + self.url = url class MerginProjectValidator(object): """Class for checking Mergin project validity and fixing the problems, if possible.""" - def __init__(self, mergin_project=None): + def __init__(self, mergin_project=None, changes=None, project_permission=None): self.mp = mergin_project self.layers = None # {layer_id: map layer} self.editable = None # list of editable layers ids @@ -88,6 +95,8 @@ def __init__(self, mergin_project=None): self.qgis_proj = None self.qgis_proj_path = None self.qgis_proj_dir = None + self.changes = changes + self.project_permission = project_permission def run_checks(self): if self.mp is None: @@ -112,6 +121,7 @@ def run_checks(self): self.check_snapping() self.check_datum_shift_grids() self.check_svgs_embedded() + self.check_editor_perms() return self.issues @@ -380,9 +390,39 @@ def check_svgs_embedded(self): self.issues.append(SingleLayerWarning(lid, Warning.SVG_NOT_EMBEDDED)) break - -def warning_display_string(warning_id): - """Returns a display string for a corresponing warning""" + def check_editor_perms(self): + if self.project_permission != "editor": + return + # editor cannot change specific files - QGS project file, mergin-config.json file (e.g. selective sync changes) + for category in self.changes: + for file in self.changes[category]: + path = file["path"] + if path.lower().endswith((".qgs", ".qgz")): + url = f"reset_file?layer={path}" + self.issues.append(MultipleLayersWarning(Warning.EDITOR_PROJECT_FILE_CHANGE, url)) + elif path.lower().endswith("mergin-config.json"): + url = f"reset_file?layer={path}" + self.issues.append(MultipleLayersWarning(Warning.EDITOR_JSON_CONFIG_CHANGE, url)) + # editor cannot do non diff-based change (e.g. schema change) + for file in self.changes["updated"]: + path = file["path"] + if is_versioned_file(path) and "diff" not in file: + layer = get_layer_by_path(path) + if layer: + url = f"reset_file?layer={path}" + self.issues.append(SingleLayerWarning(layer.id(), Warning.EDITOR_NON_DIFFABLE_CHANGE, url)) + # editor cannot delete a versioned file (e.g. '*.gpkg') + for file in self.changes["removed"]: + path = file["path"] + if is_versioned_file(path): + layer = get_layer_by_path(path) + if layer: + url = f"reset_file?layer={path}" + self.issues.append(SingleLayerWarning(layer.id(), Warning.EDITOR_DIFFBASED_FILE_REMOVED, url)) + + +def warning_display_string(warning_id, url=None): + """Returns a display string for a corresponding warning""" help_mgr = MerginHelp() if warning_id == Warning.PROJ_NOT_LOADED: return "The QGIS project is not loaded. Open it to allow validation" @@ -427,6 +467,18 @@ def warning_display_string(warning_id): elif warning_id == Warning.MERGIN_SNAPPING_NOT_ENABLED: return "Snapping is currently enabled in this QGIS project, but not enabled in Mergin Maps Input" elif warning_id == Warning.MISSING_DATUM_SHIFT_GRID: - return "Required datum shift grid is missing, reprojection may not work correctly. Fix the issue." + return "Required datum shift grid is missing, reprojection may not work correctly. Fix the issue." elif warning_id == Warning.SVG_NOT_EMBEDDED: return "SVGs used for layer styling are not embedded in the project file, as a result those symbols won't be displayed in Mergin Maps Input" + elif warning_id == Warning.EDITOR_PROJECT_FILE_CHANGE: + return ( + f"You don't have permission to edit the QGIS project file. Your changes to this file will not be sent to the server. " + f"Ask the workspace admin to upgrade your permission if you want your changes sent to the server. " + f"You can also reset this QGIS project file to the server version." + ) + elif warning_id == Warning.EDITOR_NON_DIFFABLE_CHANGE: + return f"You don't have permission to edit layer fields and properties. Ask the workspace admin to upgrade your permission or reset the layer to be able to sync changes." + elif warning_id == Warning.EDITOR_JSON_CONFIG_CHANGE: + return f"You don't have permission to change the configuration of this project. Reset the configuration to be able to sync data changes." + elif warning_id == Warning.EDITOR_DIFFBASED_FILE_REMOVED: + return f"You don't have permission to remove this layer. Reset the layer to be able to sync changes." diff --git a/docs/dev-docs.md b/docs/dev-docs.md index 88fee2fa..cb107db1 100644 --- a/docs/dev-docs.md +++ b/docs/dev-docs.md @@ -1,7 +1,7 @@ # Developer's documentation ## Development/Testing -Download python [client](https://github.com/MerginMaps/mergin-py-client) and +Download python [client](https://github.com/MerginMaps/mergin-py-client), install deps and link to qgis plugin: ``` ln -s /mergin/ /Mergin/mergin @@ -9,7 +9,7 @@ link to qgis plugin: Now link the plugin to your QGIS profile python, e.g. for MacOS ``` - ln -s /Mergin/ /QGIS3/profiles/default/python/plugins/Mergin + ln -s /Mergin/ /QGIS3/profiles/default/python/plugins/Mergin ``` ## Production