diff --git a/.gitignore b/.gitignore index b46dd1cb..6b483114 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ htmlcov .pytest_cache deps venv +.vscode/settings.json diff --git a/mergin/cli.py b/mergin/cli.py index 5607b51a..2bb24164 100755 --- a/mergin/cli.py +++ b/mergin/cli.py @@ -315,13 +315,21 @@ def share(ctx, project): return access_list = mc.project_user_permissions(project) - for username in access_list.get("owners"): + owners = access_list.get("owners", []) + writers = access_list.get("writers", []) + editors = access_list.get("editors", []) + readers = access_list.get("readers", []) + + for username in owners: click.echo("{:20}\t{:20}".format(username, "owner")) - for username in access_list.get("writers"): - if username not in access_list.get("owners"): + for username in writers: + if username not in owners: click.echo("{:20}\t{:20}".format(username, "writer")) - for username in access_list.get("readers"): - if username not in access_list.get("writers"): + for username in editors: + if username not in writers: + click.echo("{:20}\t{:20}".format(username, "editor")) + for username in readers: + if username not in editors: click.echo("{:20}\t{:20}".format(username, "reader")) diff --git a/mergin/client.py b/mergin/client.py index 4acf8ffe..50c9b6f1 100644 --- a/mergin/client.py +++ b/mergin/client.py @@ -761,8 +761,11 @@ def set_project_access(self, project_path, access): """ Updates access for given project. :param project_path: project full name (/) - :param access: dict -> list of str username we want to give access to + :param access: dict -> list of str username we want to give access to (editorsnames are only supported on servers at version 2024.4.0 or later) """ + if "editorsnames" in access and not is_version_acceptable(self.server_version(), "2024.4"): + raise NotImplementedError("Editors are only supported on servers at version 2024.4.0 or later") + if not self._user_info: raise Exception("Authentication required") @@ -782,9 +785,13 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le Add specified permissions to specified users to project :param project_path: project full name (/) :param usernames: list of usernames to be granted specified permission level - :param permission_level: string (reader, writer, owner) + :param permission_level: string (reader, editor, writer, owner) + + Editor permission_level is only supported on servers at version 2024.4.0 or later. """ - if permission_level not in ["owner", "reader", "writer"]: + if permission_level not in ["owner", "reader", "writer", "editor"] or ( + permission_level == "editor" and not is_version_acceptable(self.server_version(), "2024.4") + ): raise ClientError("Unsupported permission level") project_info = self.project_info(project_path) @@ -792,9 +799,11 @@ def add_user_permissions_to_project(self, project_path, usernames, permission_le for name in usernames: if permission_level == "owner": access.get("ownersnames").append(name) - if permission_level == "writer" or permission_level == "owner": + if permission_level in ("writer", "owner"): access.get("writersnames").append(name) - if permission_level == "writer" or permission_level == "owner" or permission_level == "reader": + if permission_level in ("writer", "owner", "editor") and "editorsnames" in access: + access.get("editorsnames").append(name) + if permission_level in ("writer", "owner", "editor", "reader"): access.get("readersnames").append(name) self.set_project_access(project_path, access) @@ -807,11 +816,13 @@ def remove_user_permissions_from_project(self, project_path, usernames): project_info = self.project_info(project_path) access = project_info.get("access") for name in usernames: - if name in access.get("ownersnames"): + if name in access.get("ownersnames", []): access.get("ownersnames").remove(name) - if name in access.get("writersnames"): + if name in access.get("writersnames", []): access.get("writersnames").remove(name) - if name in access.get("readersnames"): + if name in access.get("editorsnames", []): + access.get("editorsnames").remove(name) + if name in access.get("readersnames", []): access.get("readersnames").remove(name) self.set_project_access(project_path, access) @@ -821,14 +832,18 @@ def project_user_permissions(self, project_path): :param project_path: project full name (/) :return dict("owners": list(usernames), "writers": list(usernames), + "editors": list(usernames) - only on servers at version 2024.4.0 or later, "readers": list(usernames)) """ project_info = self.project_info(project_path) access = project_info.get("access") result = {} - result["owners"] = access.get("ownersnames") - result["writers"] = access.get("writersnames") - result["readers"] = access.get("readersnames") + if "editorsnames" in access: + result["editors"] = access.get("editorsnames", []) + + result["owners"] = access.get("ownersnames", []) + result["writers"] = access.get("writersnames", []) + result["readers"] = access.get("readersnames", []) return result def push_project(self, directory): diff --git a/mergin/test/test_client.py b/mergin/test/test_client.py index 087c1b19..849e1910 100644 --- a/mergin/test/test_client.py +++ b/mergin/test/test_client.py @@ -12,7 +12,15 @@ import time from .. import InvalidProject -from ..client import MerginClient, ClientError, MerginProject, LoginError, decode_token_data, TokenError, ServerType +from ..client import ( + MerginClient, + ClientError, + MerginProject, + LoginError, + decode_token_data, + TokenError, + ServerType, +) from ..client_push import push_project_async, push_project_cancel from ..client_pull import ( download_project_async, @@ -24,6 +32,7 @@ from ..utils import ( generate_checksum, get_versions_with_file_changes, + is_version_acceptable, unique_path_name, conflicted_copy_file_name, edit_conflict_file_name, @@ -89,6 +98,16 @@ def sudo_works(): return sudo_res.returncode != 0 +def server_has_editor_support(mc, access): + """ + Checks if the server has editor support based on the provided access information. + + Returns: + bool: True if the server has editor support, False otherwise. + """ + return "editorsnames" in access and is_version_acceptable(mc.server_version(), "2024.4") + + def test_login(mc): token = mc._auth_session["token"] assert MerginClient(mc.url, auth_token=token) @@ -238,7 +257,10 @@ def test_push_pull_changes(mc): f_removed = "test.txt" os.remove(os.path.join(project_dir, f_removed)) f_renamed = "test_dir/test2.txt" - shutil.move(os.path.normpath(os.path.join(project_dir, f_renamed)), os.path.join(project_dir, "renamed.txt")) + shutil.move( + os.path.normpath(os.path.join(project_dir, f_renamed)), + os.path.join(project_dir, "renamed.txt"), + ) f_updated = "test3.txt" with open(os.path.join(project_dir, f_updated), "w") as f: f.write("Modified") @@ -272,7 +294,10 @@ def test_push_pull_changes(mc): assert len(project_info["files"]) == len(mp.inspect_files()) project_versions = mc.project_versions(project) assert len(project_versions) == 2 - f_change = next((f for f in project_versions[-1]["changes"]["updated"] if f["path"] == f_updated), None) + f_change = next( + (f for f in project_versions[-1]["changes"]["updated"] if f["path"] == f_updated), + None, + ) assert "origin_checksum" not in f_change # internal client info # test parallel changes @@ -493,7 +518,10 @@ def test_force_gpkg_update(mc): mp.fpath(f_updated), mp.fpath_meta(f_updated) ) # make local copy for changeset calculation (which will fail) shutil.copy(os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg"), mp.fpath(f_updated)) - shutil.copy(os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg-wal"), mp.fpath(f_updated + "-wal")) + shutil.copy( + os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg-wal"), + mp.fpath(f_updated + "-wal"), + ) mc.push_project(project_dir) # by this point local file has been updated (changes committed from wal) updated_checksum = generate_checksum(mp.fpath(f_updated)) @@ -551,12 +579,18 @@ def test_missing_basefile_pull(mc): # update our gpkg in a different directory mc.download_project(project, project_dir_2) - shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"), os.path.join(project_dir_2, "base.gpkg")) + shutil.copy( + os.path.join(TEST_DATA_DIR, "inserted_1_A.gpkg"), + os.path.join(project_dir_2, "base.gpkg"), + ) mc.pull_project(project_dir_2) mc.push_project(project_dir_2) # make some other local change - shutil.copy(os.path.join(TEST_DATA_DIR, "inserted_1_B.gpkg"), os.path.join(project_dir, "base.gpkg")) + shutil.copy( + os.path.join(TEST_DATA_DIR, "inserted_1_B.gpkg"), + os.path.join(project_dir, "base.gpkg"), + ) # remove the basefile to simulate the issue os.remove(os.path.join(project_dir, ".mergin", "base.gpkg")) @@ -586,7 +620,10 @@ def test_empty_file_in_subdir(mc): # add another empty file in a different subdir os.mkdir(os.path.join(project_dir, "subdir2")) - shutil.copy(os.path.join(project_dir, "subdir", "empty.txt"), os.path.join(project_dir, "subdir2", "empty2.txt")) + shutil.copy( + os.path.join(project_dir, "subdir", "empty.txt"), + os.path.join(project_dir, "subdir2", "empty2.txt"), + ) mc.push_project(project_dir) # check that pull works fine @@ -658,13 +695,45 @@ def test_set_read_write_access(mc): access = project_info["access"] access["writersnames"].append(API_USER2) access["readersnames"].append(API_USER2) + editor_support = server_has_editor_support(mc, access) + if editor_support: + access["editorsnames"].append(API_USER2) mc.set_project_access(test_project_fullname, access) - # check access project_info = get_project_info(mc, API_USER, test_project) access = project_info["access"] assert API_USER2 in access["writersnames"] assert API_USER2 in access["readersnames"] + if editor_support: + assert API_USER2 in access["editorsnames"] + + +def test_set_editor_access(mc): + test_project = "test_set_editor_access" + test_project_fullname = API_USER + "/" + test_project + + # cleanups + project_dir = os.path.join(TMP_DIR, test_project, API_USER) + cleanup(mc, test_project_fullname, [project_dir]) + + # create new (empty) project on server + mc.create_project(test_project) + + project_info = get_project_info(mc, API_USER, test_project) + access = project_info["access"] + # Stop test if server does not support editor access + if not server_has_editor_support(mc, access): + return + + access["readersnames"].append(API_USER2) + access["editorsnames"].append(API_USER2) + mc.set_project_access(test_project_fullname, access) + # check access + project_info = get_project_info(mc, API_USER, test_project) + access = project_info["access"] + assert API_USER2 in access["editorsnames"] + assert API_USER2 in access["readersnames"] + assert API_USER2 not in access["writersnames"] def test_available_storage_validation(mc): @@ -805,7 +874,10 @@ def _generate_big_file(filepath, size): def test_get_projects_by_name(mc): """Test server 'bulk' endpoint for projects' info""" - test_projects = {"projectA": f"{API_USER}/projectA", "projectB": f"{API_USER}/projectB"} + test_projects = { + "projectA": f"{API_USER}/projectA", + "projectB": f"{API_USER}/projectB", + } for name, full_name in test_projects.items(): cleanup(mc, full_name, []) @@ -869,7 +941,11 @@ def test_paginated_project_list(mc): sorted_test_names = [n for n in sorted(test_projects.keys())] resp = mc.paginated_projects_list( - flag="created", name="test_paginated", page=1, per_page=10, order_params="name_asc" + flag="created", + name="test_paginated", + page=1, + per_page=10, + order_params="name_asc", ) projects = resp["projects"] count = resp["count"] @@ -879,7 +955,11 @@ def test_paginated_project_list(mc): assert project["name"] == sorted_test_names[i] resp = mc.paginated_projects_list( - flag="created", name="test_paginated", page=2, per_page=2, order_params="name_asc" + flag="created", + name="test_paginated", + page=2, + per_page=2, + order_params="name_asc", ) projects = resp["projects"] assert len(projects) == 2 @@ -964,8 +1044,14 @@ def create_versioned_project(mc, project_name, project_dir, updated_file, remove # create version with forced overwrite (broken history) if overwrite: shutil.move(mp.fpath(updated_file), mp.fpath_meta(updated_file)) - shutil.copy(os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg"), mp.fpath(updated_file)) - shutil.copy(os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg-wal"), mp.fpath(updated_file + "-wal")) + shutil.copy( + os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg"), + mp.fpath(updated_file), + ) + shutil.copy( + os.path.join(CHANGED_SCHEMA_DIR, "modified_schema.gpkg-wal"), + mp.fpath(updated_file + "-wal"), + ) mc.push_project(project_dir) return mp @@ -986,13 +1072,23 @@ def test_get_versions_with_file_changes(mc): with pytest.raises(ClientError) as e: mod_versions = get_versions_with_file_changes( - mc, project, f_updated, version_from="v1", version_to="v5", file_history=file_history + mc, + project, + f_updated, + version_from="v1", + version_to="v5", + file_history=file_history, ) assert "Wrong version parameters: 1-5" in str(e.value) assert "Available versions: [1, 2, 3, 4]" in str(e.value) mod_versions = get_versions_with_file_changes( - mc, project, f_updated, version_from="v2", version_to="v4", file_history=file_history + mc, + project, + f_updated, + version_from="v2", + version_to="v4", + file_history=file_history, ) assert mod_versions == [f"v{i}" for i in range(2, 5)] @@ -1019,7 +1115,11 @@ def test_download_file(mc): assert project_info["id"] == mp.project_id() # Versioned file should have the following content at versions 2-4 - expected_content = ("inserted_1_A.gpkg", "inserted_1_A_mod.gpkg", "inserted_1_B.gpkg") + expected_content = ( + "inserted_1_A.gpkg", + "inserted_1_A_mod.gpkg", + "inserted_1_B.gpkg", + ) # Download the base file at versions 2-4 and check the changes f_downloaded = os.path.join(project_dir, f_updated) @@ -1103,12 +1203,18 @@ def test_modify_project_permissions(mc): assert permissions["owners"] == [API_USER] assert permissions["writers"] == [API_USER] assert permissions["readers"] == [API_USER] + editor_support = server_has_editor_support(mc, permissions) + if editor_support: + assert permissions["editors"] == [API_USER] mc.add_user_permissions_to_project(project, [API_USER2], "writer") permissions = mc.project_user_permissions(project) assert set(permissions["owners"]) == {API_USER} assert set(permissions["writers"]) == {API_USER, API_USER2} assert set(permissions["readers"]) == {API_USER, API_USER2} + editor_support = server_has_editor_support(mc, permissions) + if editor_support: + assert set(permissions["editors"]) == {API_USER, API_USER2} mc.remove_user_permissions_from_project(project, [API_USER2]) permissions = mc.project_user_permissions(project) @@ -1116,6 +1222,10 @@ def test_modify_project_permissions(mc): assert permissions["writers"] == [API_USER] assert permissions["readers"] == [API_USER] + editor_support = server_has_editor_support(mc, permissions) + if editor_support: + assert permissions["editors"] == [API_USER] + def _use_wal(db_file): """Ensures that sqlite database is using WAL journal mode""" @@ -1194,7 +1304,11 @@ class AnotherSqliteConn: def __init__(self, filename): self.proc = subprocess.Popen( - ["python3", os.path.join(os.path.dirname(__file__), "sqlite_con.py"), filename], + [ + "python3", + os.path.join(os.path.dirname(__file__), "sqlite_con.py"), + filename, + ], stdin=subprocess.PIPE, stderr=subprocess.PIPE, ) @@ -1477,18 +1591,48 @@ def test_conflict_file_names(): """ data = [ - ("/home/test/geo.gpkg", "jack", 10, "/home/test/geo (conflicted copy, jack v10).gpkg"), + ( + "/home/test/geo.gpkg", + "jack", + 10, + "/home/test/geo (conflicted copy, jack v10).gpkg", + ), ("/home/test/g.pkg", "j", 0, "/home/test/g (conflicted copy, j v0).pkg"), - ("home/test/geo.gpkg", "jack", 10, "home/test/geo (conflicted copy, jack v10).gpkg"), + ( + "home/test/geo.gpkg", + "jack", + 10, + "home/test/geo (conflicted copy, jack v10).gpkg", + ), ("geo.gpkg", "jack", 10, "geo (conflicted copy, jack v10).gpkg"), ("/home/../geo.gpkg", "jack", 10, "/geo (conflicted copy, jack v10).gpkg"), ("/home/./geo.gpkg", "jack", 10, "/home/geo (conflicted copy, jack v10).gpkg"), ("/home/test/geo.gpkg", "", 10, "/home/test/geo (conflicted copy, v10).gpkg"), - ("/home/test/geo.gpkg", "jack", -1, "/home/test/geo (conflicted copy, jack v-1).gpkg"), - ("/home/test/geo.tar.gz", "jack", 100, "/home/test/geo (conflicted copy, jack v100).tar.gz"), + ( + "/home/test/geo.gpkg", + "jack", + -1, + "/home/test/geo (conflicted copy, jack v-1).gpkg", + ), + ( + "/home/test/geo.tar.gz", + "jack", + 100, + "/home/test/geo (conflicted copy, jack v100).tar.gz", + ), ("", "jack", 1, ""), - ("/home/test/survey.qgs", "jack", 10, "/home/test/survey (conflicted copy, jack v10).qgs~"), - ("/home/test/survey.QGZ", "jack", 10, "/home/test/survey (conflicted copy, jack v10).QGZ~"), + ( + "/home/test/survey.qgs", + "jack", + 10, + "/home/test/survey (conflicted copy, jack v10).qgs~", + ), + ( + "/home/test/survey.QGZ", + "jack", + 10, + "/home/test/survey (conflicted copy, jack v10).QGZ~", + ), ] for i in data: @@ -1496,16 +1640,41 @@ def test_conflict_file_names(): assert file_name == i[3] data = [ - ("/home/test/geo.json", "jack", 10, "/home/test/geo (edit conflict, jack v10).json"), + ( + "/home/test/geo.json", + "jack", + 10, + "/home/test/geo (edit conflict, jack v10).json", + ), ("/home/test/g.jsn", "j", 0, "/home/test/g (edit conflict, j v0).json"), - ("home/test/geo.json", "jack", 10, "home/test/geo (edit conflict, jack v10).json"), + ( + "home/test/geo.json", + "jack", + 10, + "home/test/geo (edit conflict, jack v10).json", + ), ("geo.json", "jack", 10, "geo (edit conflict, jack v10).json"), ("/home/../geo.json", "jack", 10, "/geo (edit conflict, jack v10).json"), ("/home/./geo.json", "jack", 10, "/home/geo (edit conflict, jack v10).json"), ("/home/test/geo.json", "", 10, "/home/test/geo (edit conflict, v10).json"), - ("/home/test/geo.json", "jack", -1, "/home/test/geo (edit conflict, jack v-1).json"), - ("/home/test/geo.gpkg", "jack", 10, "/home/test/geo (edit conflict, jack v10).json"), - ("/home/test/geo.tar.gz", "jack", 100, "/home/test/geo (edit conflict, jack v100).json"), + ( + "/home/test/geo.json", + "jack", + -1, + "/home/test/geo (edit conflict, jack v-1).json", + ), + ( + "/home/test/geo.gpkg", + "jack", + 10, + "/home/test/geo (edit conflict, jack v10).json", + ), + ( + "/home/test/geo.tar.gz", + "jack", + 100, + "/home/test/geo (edit conflict, jack v100).json", + ), ("", "jack", 1, ""), ] @@ -1539,8 +1708,18 @@ def test_unique_path_names(): # - another (2).txt # - arch.tar.gz data = { - "folderA": {"files": ["fileA.txt", "fileA (1).txt", "fileB.txt"], "folderAB": {}, "folderAB (1)": {}}, - "files": ["file.txt", "another.txt", "another (1).txt", "another (2).txt", "arch.tar.gz"], + "folderA": { + "files": ["fileA.txt", "fileA (1).txt", "fileB.txt"], + "folderAB": {}, + "folderAB (1)": {}, + }, + "files": [ + "file.txt", + "another.txt", + "another (1).txt", + "another (2).txt", + "arch.tar.gz", + ], } create_directory(project_dir, data) @@ -1817,7 +1996,18 @@ def test_report(mc): with open(report_file, "r") as rf: content = rf.read() headers = ",".join( - ["file", "table", "author", "date", "time", "version", "operation", "length", "area", "count"] + [ + "file", + "table", + "author", + "date", + "time", + "version", + "operation", + "length", + "area", + "count", + ] ) assert headers in content assert "base.gpkg,simple,test_plugin" in content @@ -1839,7 +2029,7 @@ def test_report(mc): create_report(mc, directory, since, to, report_file) -def test_project_versions_list(mc, mc2): +def test_user_permissions(mc, mc2): """ Test retrieving user permissions """ @@ -2215,7 +2405,10 @@ def test_project_rename(mc: MerginClient): mc.rename_project(API_USER + "/" + "non_existing_project", "new_project") # cannot rename with full project name - with pytest.raises(ClientError, match="Project's new name should be without workspace specification"): + with pytest.raises( + ClientError, + match="Project's new name should be without workspace specification", + ): mc.rename_project(project, "workspace" + "/" + test_project_renamed) @@ -2236,7 +2429,11 @@ def test_download_files(mc: MerginClient): assert project_info["id"] == mp.project_id() # Versioned file should have the following content at versions 2-4 - expected_content = ("inserted_1_A.gpkg", "inserted_1_A_mod.gpkg", "inserted_1_B.gpkg") + expected_content = ( + "inserted_1_A.gpkg", + "inserted_1_A_mod.gpkg", + "inserted_1_B.gpkg", + ) downloaded_file = os.path.join(download_dir, f_updated) @@ -2256,7 +2453,12 @@ def test_download_files(mc: MerginClient): file_2 = "test.txt" downloaded_file_2 = os.path.join(download_dir, file_2) - mc.download_files(project_dir, [f_updated, file_2], [downloaded_file, downloaded_file_2], version="v1") + mc.download_files( + project_dir, + [f_updated, file_2], + [downloaded_file, downloaded_file_2], + version="v1", + ) assert check_gpkg_same_content(mp, downloaded_file, os.path.join(TEST_DATA_DIR, f_updated)) with open(os.path.join(TEST_DATA_DIR, file_2), mode="r", encoding="utf-8") as file: