Skip to content

Commit dafe08d

Browse files
authored
CLI to add, remove and list collaborators (#111)
Fixes #98
1 parent c192b3f commit dafe08d

File tree

6 files changed

+161
-8
lines changed

6 files changed

+161
-8
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ Commands:
6464
show-file-history Displays information about a single version of a...
6565
show-version Displays information about a single version of a...
6666
status Show all changes in project files - upstream and...
67+
share Show project permissions
68+
share-add Add user to project permissions
69+
share-remove Remove user from project's collaborators
6770
```
6871

6972
For example, to download a project:
@@ -111,6 +114,10 @@ it is possible to run other commands without specifying username/password.
111114

112115
## Development
113116

117+
### Setup local dependencies
118+
pip install -e ../
119+
120+
114121
### How to release
115122

116123
1. Update version in `setup.py` and `mergin/version.py`

mergin/cli.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@
3131
download_project_finalize,
3232
download_project_is_running,
3333
)
34-
from mergin.client_pull import pull_project_async, pull_project_is_running, pull_project_finalize, pull_project_cancel
35-
from mergin.client_push import push_project_async, push_project_is_running, push_project_finalize, push_project_cancel
34+
from mergin.client_pull import pull_project_async, pull_project_is_running, pull_project_finalize, \
35+
pull_project_cancel
36+
from mergin.client_push import push_project_async, push_project_is_running, push_project_finalize, \
37+
push_project_cancel
38+
3639

3740
from pygeodiff import GeoDiff
3841

@@ -269,6 +272,53 @@ def download(ctx, project, directory, version):
269272
_print_unhandled_exception()
270273

271274

275+
@cli.command()
276+
@click.argument("project")
277+
@click.argument("usernames", nargs=-1)
278+
@click.option("--permissions", help="permissions to be granted to project (reader, writer, owner)")
279+
@click.pass_context
280+
def share_add(ctx, project, usernames, permissions):
281+
"""Add permissions to [users] to project"""
282+
mc = ctx.obj["client"]
283+
if mc is None:
284+
return
285+
usernames = list(usernames)
286+
mc.add_user_permissions_to_project(project, usernames, permissions)
287+
288+
289+
@cli.command()
290+
@click.argument("project")
291+
@click.argument("usernames", nargs=-1)
292+
@click.pass_context
293+
def share_remove(ctx, project, usernames):
294+
"""Remove [users] permissions from project"""
295+
mc = ctx.obj["client"]
296+
if mc is None:
297+
return
298+
usernames = list(usernames)
299+
mc.remove_user_permissions_from_project(project, usernames)
300+
301+
302+
@cli.command()
303+
@click.argument("project")
304+
@click.pass_context
305+
def share(ctx, project):
306+
"""Fetch permissions to project"""
307+
mc = ctx.obj["client"]
308+
if mc is None:
309+
return
310+
access_list = mc.project_user_permissions(project)
311+
312+
for username in access_list.get("owners"):
313+
click.echo("{:20}\t{:20}".format(username, "owner"))
314+
for username in access_list.get("writers"):
315+
if username not in access_list.get("owners"):
316+
click.echo("{:20}\t{:20}".format(username, "writer"))
317+
for username in access_list.get("readers"):
318+
if username not in access_list.get("writers"):
319+
click.echo("{:20}\t{:20}".format(username, "reader"))
320+
321+
272322
@cli.command()
273323
@click.argument("filepath")
274324
@click.argument("output")

mergin/client.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,60 @@ def set_project_access(self, project_path, access):
519519
detail = f"Project path: {project_path}"
520520
raise ClientError(str(e), detail)
521521

522+
def add_user_permissions_to_project(self, project_path, usernames, permission_level):
523+
"""
524+
Add specified permissions to specified users to project
525+
:param project_path: project full name (<namespace>/<name>)
526+
:param usernames: list of usernames to be granted specified permission level
527+
:param permission_level: string (reader, writer, owner)
528+
"""
529+
if permission_level not in ["owner", "reader", "writer"]:
530+
raise ClientError("Unsupported permission level")
531+
532+
project_info = self.project_info(project_path)
533+
access = project_info.get('access')
534+
for name in usernames:
535+
if permission_level == "owner":
536+
access.get("ownersnames").append(name)
537+
if permission_level == "writer" or permission_level == "owner":
538+
access.get("writersnames").append(name)
539+
if permission_level == "writer" or permission_level == "owner" or permission_level == "reader":
540+
access.get("readersnames").append(name)
541+
self.set_project_access(project_path, access)
542+
543+
def remove_user_permissions_from_project(self, project_path, usernames):
544+
"""
545+
Removes specified users from project
546+
:param project_path: project full name (<namespace>/<name>)
547+
:param usernames: list of usernames to be granted specified permission level
548+
"""
549+
project_info = self.project_info(project_path)
550+
access = project_info.get('access')
551+
for name in usernames:
552+
if name in access.get("ownersnames"):
553+
access.get("ownersnames").remove(name)
554+
if name in access.get("writersnames"):
555+
access.get("writersnames").remove(name)
556+
if name in access.get("readersnames"):
557+
access.get("readersnames").remove(name)
558+
self.set_project_access(project_path, access)
559+
560+
def project_user_permissions(self, project_path):
561+
"""
562+
Returns permissions for project
563+
:param project_path: project full name (<namespace>/<name>)
564+
:return dict("owners": list(usernames),
565+
"writers": list(usernames),
566+
"readers": list(usernames))
567+
"""
568+
project_info = self.project_info(project_path)
569+
access = project_info.get('access')
570+
result = {}
571+
result["owners"] = access.get("ownersnames")
572+
result["writers"] = access.get("writersnames")
573+
result["readers"] = access.get("readersnames")
574+
return result
575+
522576
def push_project(self, directory):
523577
"""
524578
Upload local changes to the repository.

mergin/client_push.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ def upload_blocking(self, mc, mp):
7070
resp_dict = json.load(resp)
7171
mp.log.debug(f"Upload finished: {self.file_path}")
7272
if not (resp_dict['size'] == len(data) and resp_dict['checksum'] == checksum.hexdigest()):
73-
mc.post("/v1/project/push/cancel/{}".format(self.transaction_id))
73+
try:
74+
mc.post("/v1/project/push/cancel/{}".format(self.transaction_id))
75+
except ClientError:
76+
pass
7477
raise ClientError("Mismatch between uploaded file chunk {} and local one".format(self.chunk_id))
7578

7679

@@ -263,9 +266,11 @@ def push_project_finalize(job):
263266
# if push finish fails, the transaction is not killed, so we
264267
# need to cancel it so it does not block further uploads
265268
job.mp.log.info("canceling the pending transaction...")
266-
resp_cancel = job.mc.post("/v1/project/push/cancel/%s" % job.transaction_id)
267-
server_resp_cancel = json.load(resp_cancel)
268-
job.mp.log.info("cancel response: " + str(server_resp_cancel))
269+
try:
270+
resp_cancel = job.mc.post("/v1/project/push/cancel/%s" % job.transaction_id)
271+
job.mp.log.info("cancel response: " + resp_cancel.msg)
272+
except ClientError as err2:
273+
job.mp.log.info("cancel response: " + str(err2))
269274
raise err
270275

271276
job.mp.metadata = {
@@ -293,7 +298,7 @@ def push_project_cancel(job):
293298
job.executor.shutdown(wait=True)
294299
try:
295300
resp_cancel = job.mc.post("/v1/project/push/cancel/%s" % job.transaction_id)
296-
job.server_resp = json.load(resp_cancel)
301+
job.server_resp = resp_cancel.msg
297302
except ClientError as err:
298303
job.mp.log.error("--- push cancelling failed! " + str(err))
299304
raise err

mergin/merginproject.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
# python paths.
2222
try:
2323
from .deps import pygeodiff
24-
except ImportError:
24+
except (ImportError, ModuleNotFoundError):
2525
import pygeodiff
2626

2727

mergin/test/test_client.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,43 @@ def test_download_diffs(mc):
941941
assert "Available versions: [1, 2, 3, 4]" in str(e.value)
942942

943943

944+
def test_modify_project_permissions(mc):
945+
test_project = 'test_project'
946+
project = API_USER + '/' + test_project
947+
project_dir = os.path.join(TMP_DIR, test_project)
948+
download_dir = os.path.join(TMP_DIR, 'download', test_project)
949+
950+
cleanup(mc, project, [project_dir, download_dir])
951+
# prepare local project
952+
shutil.copytree(TEST_DATA_DIR, project_dir)
953+
954+
# create remote project
955+
mc.create_project_and_push(test_project, directory=project_dir)
956+
957+
# check basic metadata about created project
958+
project_info = mc.project_info(project)
959+
assert project_info['version'] == 'v1'
960+
assert project_info['name'] == test_project
961+
assert project_info['namespace'] == API_USER
962+
963+
permissions = mc.project_user_permissions(project)
964+
assert permissions["owners"] == [API_USER]
965+
assert permissions["writers"] == [API_USER]
966+
assert permissions["readers"] == [API_USER]
967+
968+
mc.add_user_permissions_to_project(project, [API_USER2], "writer")
969+
permissions = mc.project_user_permissions(project)
970+
assert set(permissions["owners"]) == {API_USER}
971+
assert set(permissions["writers"]) == {API_USER, API_USER2}
972+
assert set(permissions["readers"]) == {API_USER, API_USER2}
973+
974+
mc.remove_user_permissions_from_project(project, [API_USER2])
975+
permissions = mc.project_user_permissions(project)
976+
assert permissions["owners"] == [API_USER]
977+
assert permissions["writers"] == [API_USER]
978+
assert permissions["readers"] == [API_USER]
979+
980+
944981
def _use_wal(db_file):
945982
""" Ensures that sqlite database is using WAL journal mode """
946983
con = sqlite3.connect(db_file)

0 commit comments

Comments
 (0)