Skip to content

Commit 374a6b2

Browse files
MarcelGeowonder-sk
authored andcommitted
Added initial push handling for editors
1 parent d352c8c commit 374a6b2

File tree

5 files changed

+216
-19
lines changed

5 files changed

+216
-19
lines changed

mergin/client.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ def set_project_access(self, project_path, access):
763763
:param project_path: project full name (<namespace>/<name>)
764764
:param access: dict <readersnames, editorsnames, writersnames, ownersnames> -> list of str username we want to give access to (editorsnames are only supported on servers at version 2024.4.0 or later)
765765
"""
766-
if "editorsnames" in access and not is_version_acceptable(self.server_version(), "2024.4"):
766+
if "editorsnames" in access and not self.has_editor_support():
767767
raise NotImplementedError("Editors are only supported on servers at version 2024.4.0 or later")
768768

769769
if not self._user_info:
@@ -790,7 +790,7 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le
790790
Editor permission_level is only supported on servers at version 2024.4.0 or later.
791791
"""
792792
if permission_level not in ["owner", "reader", "writer", "editor"] or (
793-
permission_level == "editor" and not is_version_acceptable(self.server_version(), "2024.4")
793+
permission_level == "editor" and not self.has_editor_support()
794794
):
795795
raise ClientError("Unsupported permission level")
796796

@@ -1235,3 +1235,9 @@ def download_files(
12351235
job = download_files_async(self, project_dir, file_paths, output_paths, version=version)
12361236
pull_project_wait(job)
12371237
download_files_finalize(job)
1238+
1239+
def has_editor_support(self):
1240+
"""
1241+
Returns whether the server version is acceptable for editor support.
1242+
"""
1243+
return is_version_acceptable(self.server_version(), "2024.4.0")

mergin/client_push.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from .common import UPLOAD_CHUNK_SIZE, ClientError
2020
from .merginproject import MerginProject
21+
from .editor import EditorHandler
2122

2223

2324
class UploadJob:
@@ -91,12 +92,12 @@ def push_project_async(mc, directory):
9192
mp.log.info(f"--- start push {project_path}")
9293

9394
try:
94-
server_info = mc.project_info(project_path)
95+
project_info = mc.project_info(project_path)
9596
except ClientError as err:
9697
mp.log.error("Error getting project info: " + str(err))
9798
mp.log.info("--- push aborted")
9899
raise
99-
server_version = server_info["version"] if server_info["version"] else "v0"
100+
server_version = project_info["version"] if project_info["version"] else "v0"
100101

101102
mp.log.info(f"got project info: local version {local_version} / server version {server_version}")
102103

@@ -132,6 +133,9 @@ def push_project_async(mc, directory):
132133
for f in changes["added"]:
133134
if mp.is_versioned_file(f["path"]):
134135
mp.copy_versioned_file_for_upload(f, tmp_dir.name)
136+
137+
editorHandler = EditorHandler(mc, project_info)
138+
changes = editorHandler.filter_changes(changes)
135139

136140
if not sum(len(v) for v in changes.values()):
137141
mp.log.info(f"--- push {project_path} - nothing to do")

mergin/editor.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from itertools import filterfalse
2+
3+
from .utils import is_mergin_config, is_qgis_file, is_versioned_file
4+
5+
6+
class EditorHandler:
7+
def __init__(self, mergin_client, project_info: dict):
8+
"""
9+
Initializes the editor instance with the provided Mergin client and project information.
10+
11+
Args:
12+
mergin_client (MerginClient): The Mergin client instance.
13+
project_info (dict): The project information dictionary.
14+
"""
15+
self.mc = mergin_client
16+
self.project_info = project_info
17+
self.ROLE_NAME = "editor"
18+
19+
_disallowed_added_changes = lambda self, change: is_qgis_file(change["path"]) or is_mergin_config(change["path"])
20+
_disallowed_updated_changes = (
21+
lambda self, change: is_qgis_file(change["path"])
22+
or is_mergin_config(change["path"])
23+
or (is_versioned_file(change["path"]) and change.get("diff") is None)
24+
)
25+
_disallowed_removed_changes = (
26+
lambda self, change: is_qgis_file(change["path"])
27+
or is_mergin_config(change["path"])
28+
or is_versioned_file(change["path"])
29+
)
30+
31+
def is_enabled(self):
32+
"""
33+
Returns True if the current user has editor access to the project, False otherwise.
34+
35+
The method checks if the server supports editor access, and if the current user's project role matches the expected role name for editors.
36+
"""
37+
server_support_editor = self.mc.has_editor_support()
38+
project_role = self.project_info.get("role")
39+
40+
return server_support_editor and project_role == self.ROLE_NAME
41+
42+
def _apply_editor_filters(self, changes: dict[str, list[dict]]) -> dict[str, list[dict]]:
43+
added = changes.get("added", [])
44+
updated = changes.get("updated", [])
45+
removed = changes.get("removed", [])
46+
47+
# filter out files that are not in the editor's list of allowed files
48+
changes["added"] = list(filterfalse(self._disallowed_added_changes, added))
49+
changes["updated"] = list(filterfalse(self._disallowed_updated_changes, updated))
50+
changes["removed"] = list(filterfalse(self._disallowed_removed_changes, removed))
51+
return changes
52+
53+
def filter_changes(self, changes: dict[str, list[dict]]) -> dict[str, list[dict]]:
54+
if not self.is_enabled():
55+
return changes
56+
return self._apply_editor_filters(changes)

mergin/test/test_client.py

Lines changed: 129 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
)
4040
from ..merginproject import pygeodiff
4141
from ..report import create_report
42+
from ..editor import EditorHandler
4243

4344

4445
SERVER_URL = os.environ.get("TEST_MERGIN_URL")
@@ -113,7 +114,12 @@ def server_has_editor_support(mc, access):
113114
Returns:
114115
bool: True if the server has editor support, False otherwise.
115116
"""
116-
return "editorsnames" in access and is_version_acceptable(mc.server_version(), "2024.4")
117+
return "editorsnames" in access and mc.has_editor_support()
118+
119+
120+
def test_client_instance(mc, mc2):
121+
assert isinstance(mc, MerginClient)
122+
assert isinstance(mc2, MerginClient)
117123

118124

119125
def test_login(mc):
@@ -1191,42 +1197,36 @@ def test_download_diffs(mc):
11911197

11921198
def test_modify_project_permissions(mc):
11931199
test_project = "test_project"
1194-
project = API_USER + "/" + test_project
1200+
test_project_fullname = API_USER + "/" + test_project
11951201
project_dir = os.path.join(TMP_DIR, test_project)
11961202
download_dir = os.path.join(TMP_DIR, "download", test_project)
11971203

1198-
cleanup(mc, project, [project_dir, download_dir])
1204+
cleanup(mc, test_project_fullname, [project_dir, download_dir])
11991205
# prepare local project
12001206
shutil.copytree(TEST_DATA_DIR, project_dir)
12011207

12021208
# create remote project
1203-
mc.create_project_and_push(project, directory=project_dir)
1204-
1205-
# check basic metadata about created project
1206-
project_info = mc.project_info(project)
1207-
assert project_info["version"] == "v1"
1208-
assert project_info["name"] == test_project
1209-
assert project_info["namespace"] == API_USER
1209+
mc.create_project_and_push(test_project_fullname, directory=project_dir)
12101210

1211-
permissions = mc.project_user_permissions(project)
1211+
permissions = mc.project_user_permissions(test_project_fullname)
12121212
assert permissions["owners"] == [API_USER]
12131213
assert permissions["writers"] == [API_USER]
12141214
assert permissions["readers"] == [API_USER]
12151215
editor_support = server_has_editor_support(mc, permissions)
12161216
if editor_support:
12171217
assert permissions["editors"] == [API_USER]
12181218

1219-
mc.add_user_permissions_to_project(project, [API_USER2], "writer")
1220-
permissions = mc.project_user_permissions(project)
1219+
mc.add_user_permissions_to_project(test_project_fullname, [API_USER2], "writer")
1220+
permissions = mc.project_user_permissions(test_project_fullname)
12211221
assert set(permissions["owners"]) == {API_USER}
12221222
assert set(permissions["writers"]) == {API_USER, API_USER2}
12231223
assert set(permissions["readers"]) == {API_USER, API_USER2}
12241224
editor_support = server_has_editor_support(mc, permissions)
12251225
if editor_support:
12261226
assert set(permissions["editors"]) == {API_USER, API_USER2}
12271227

1228-
mc.remove_user_permissions_from_project(project, [API_USER2])
1229-
permissions = mc.project_user_permissions(project)
1228+
mc.remove_user_permissions_from_project(test_project_fullname, [API_USER2])
1229+
permissions = mc.project_user_permissions(test_project_fullname)
12301230
assert permissions["owners"] == [API_USER]
12311231
assert permissions["writers"] == [API_USER]
12321232
assert permissions["readers"] == [API_USER]
@@ -2524,3 +2524,117 @@ def test_download_failure(mc):
25242524
with open(job.failure_log_file, "r", encoding="utf-8") as f:
25252525
content = f.read()
25262526
assert "Traceback" in content
2527+
2528+
2529+
# TODO: consider to use separate test_editor.py file for tests that require editor
2530+
def test_editor_handler(mc: MerginClient):
2531+
"""Test editor handler class and push with editor"""
2532+
2533+
project_info = {"role": "editor"}
2534+
# intilize handler
2535+
editor_handler = EditorHandler(mc, project_info)
2536+
if not mc.has_editor_support():
2537+
assert editor_handler.is_enabled() is False
2538+
return
2539+
2540+
# mock that user is editor
2541+
project_info["role"] = editor_handler.ROLE_NAME
2542+
assert editor_handler.is_enabled() is True
2543+
2544+
# unit test for EditorHandler methods
2545+
qgs_changeset = {
2546+
"added": [{"path": "/folder/project.new.Qgz"}],
2547+
"updated": [{"path": "/folder/project.updated.Qgs"}],
2548+
"removed": [{"path": "/folder/project.removed.qgs"}],
2549+
}
2550+
qgs_changeset = editor_handler.filter_changes(qgs_changeset)
2551+
assert sum(len(v) for v in qgs_changeset.values()) == 0
2552+
2553+
mergin_config_changeset = {
2554+
"added": [{"path": "/.mergin/mergin-config.json"}],
2555+
"updated": [{"path": "/.mergin/mergin-config.json"}],
2556+
"removed": [{"path": "/.mergin/mergin-config.json"}],
2557+
}
2558+
mergin_config_changeset = editor_handler.filter_changes(mergin_config_changeset)
2559+
assert sum(len(v) for v in mergin_config_changeset.values()) == 0
2560+
2561+
gpkg_changeset = {
2562+
"added": [{"path": "/.mergin/data.gpkg"}],
2563+
"updated": [{"path": "/.mergin/conflict-data.gpkg"}, {"path": "/.mergin/data.gpkg", "diff": {}}],
2564+
"removed": [{"path": "/.mergin/data.gpkg"}],
2565+
}
2566+
gpkg_changeset = editor_handler.filter_changes(gpkg_changeset)
2567+
assert sum(len(v) for v in gpkg_changeset.values()) == 2
2568+
assert gpkg_changeset["added"][0]["path"] == "/.mergin/data.gpkg"
2569+
assert gpkg_changeset["updated"][0]["path"] == "/.mergin/data.gpkg"
2570+
2571+
2572+
def test_editor_push(mc: MerginClient, mc2: MerginClient):
2573+
"""Test push with editor"""
2574+
if not mc.has_editor_support():
2575+
return
2576+
test_project = "test_editor_push"
2577+
test_project_fullname = API_USER + "/" + test_project
2578+
project = API_USER + "/" + test_project
2579+
project_dir = os.path.join(TMP_DIR, test_project)
2580+
cleanup(mc, project, [project_dir])
2581+
2582+
# create new (empty) project on server
2583+
# TODO: return project_info from create project, don't use project_full name for project info, instead returned id of project
2584+
mc.create_project(test_project)
2585+
2586+
mc.add_user_permissions_to_project(project, [API_USER2], "editor")
2587+
project_info = mc2.project_info(test_project_fullname)
2588+
editor_handler = EditorHandler(mc2, project_info)
2589+
assert project_info["role"] == editor_handler.ROLE_NAME
2590+
assert editor_handler.is_enabled() is True
2591+
2592+
# download empty project
2593+
mc2.download_project(test_project_fullname, project_dir)
2594+
2595+
# editor is starting to adding qgis files and "normal" file
2596+
qgs_file_name = "test.qgs"
2597+
txt_file_name = "test.txt"
2598+
gpkg_file_name = "base.gpkg"
2599+
files_to_push = [qgs_file_name, txt_file_name, gpkg_file_name]
2600+
for file in files_to_push:
2601+
shutil.copy(os.path.join(TEST_DATA_DIR, file), project_dir)
2602+
# it's possible to push allowed files if editor
2603+
mc2.push_project(project_dir)
2604+
project_info = mc2.project_info(test_project_fullname)
2605+
assert len(project_info.get("files")) == len(files_to_push) - 1 # ggs is not pushed
2606+
# find pushed files in server
2607+
assert any(file["path"] == qgs_file_name for file in project_info.get("files")) is False
2608+
assert any(file["path"] == txt_file_name for file in project_info.get("files")) is True
2609+
assert any(file["path"] == gpkg_file_name for file in project_info.get("files")) is True
2610+
pull_changes, push_changes, push_changes_summary = mc.project_status(project_dir)
2611+
assert not sum(len(v) for v in pull_changes.values())
2612+
assert sum(len(v) for v in push_changes.values()) == 1
2613+
# ggs is still waiting to push
2614+
assert any(file["path"] == qgs_file_name for file in push_changes.get("added")) is True
2615+
2616+
# editor is trying to psuh row to gpkg file -> it's possible
2617+
shutil.copy(
2618+
os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"),
2619+
os.path.join(project_dir, gpkg_file_name),
2620+
)
2621+
mc2.push_project(project_dir)
2622+
project_info = mc2.project_info(test_project_fullname)
2623+
pull_changes, push_changes, push_changes_summary = mc.project_status(project_dir)
2624+
assert any(file["path"] == gpkg_file_name for file in project_info.get("files")) is True
2625+
assert any(file["path"] == gpkg_file_name for file in push_changes.get("updated")) is False
2626+
2627+
# editor is trying to insert tables to gpkg file
2628+
shutil.copy(
2629+
os.path.join(TEST_DATA_DIR, "two_tables.gpkg"),
2630+
os.path.join(project_dir, gpkg_file_name),
2631+
)
2632+
mc2.push_project(project_dir)
2633+
pull_changes, push_changes, push_changes_summary = mc.project_status(project_dir)
2634+
assert not sum(len(v) for v in pull_changes.values())
2635+
# gpkg was filter by editor_handler in push_project, because new tables added
2636+
assert sum(len(v) for v in push_changes.values()) == 2
2637+
# ggs and gpkg are still waiting to push
2638+
assert any(file["path"] == qgs_file_name for file in push_changes.get("added")) is True
2639+
assert any(file["path"] == gpkg_file_name for file in push_changes.get("updated")) is True
2640+

mergin/utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,3 +255,20 @@ def is_version_acceptable(version, min_version):
255255
major, minor = m.group(1), m.group(2)
256256

257257
return major > min_major or (major == min_major and minor >= min_minor)
258+
259+
def is_versioned_file(path: str) -> bool:
260+
diff_extensions = [".gpkg", ".sqlite"]
261+
f_extension = os.path.splitext(path)[1]
262+
return f_extension.lower() in diff_extensions
263+
264+
def is_qgis_file(path: str) -> bool:
265+
"""
266+
Check if file is a QGIS project file.
267+
"""
268+
f_extension = os.path.splitext(path)[1]
269+
return f_extension.lower() in [".qgs", ".qgz"]
270+
271+
def is_mergin_config(path: str) -> bool:
272+
"""Check if the given path is for file mergin-config.json"""
273+
filename = os.path.basename(path).lower()
274+
return filename == "mergin-config.json"

0 commit comments

Comments
 (0)