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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ Commands:
show-file-history Displays information about a single version of a...
show-version Displays information about a single version of a...
status Show all changes in project files - upstream and...
share Show project permissions
share-add Add user to project permissions
share-remove Remove user from project's collaborators
```

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

## Development

### Setup local dependencies
pip install -e ../


### How to release

1. Update version in `setup.py` and `mergin/version.py`
Expand Down
54 changes: 52 additions & 2 deletions mergin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@
download_project_finalize,
download_project_is_running,
)
from mergin.client_pull import pull_project_async, pull_project_is_running, pull_project_finalize, pull_project_cancel
from mergin.client_push import push_project_async, push_project_is_running, push_project_finalize, push_project_cancel
from mergin.client_pull import pull_project_async, pull_project_is_running, pull_project_finalize, \
pull_project_cancel
from mergin.client_push import push_project_async, push_project_is_running, push_project_finalize, \
push_project_cancel


from pygeodiff import GeoDiff

Expand Down Expand Up @@ -269,6 +272,53 @@ def download(ctx, project, directory, version):
_print_unhandled_exception()


@cli.command()
@click.argument("project")
@click.argument("usernames", nargs=-1)
@click.option("--permissions", help="permissions to be granted to project (reader, writer, owner)")
@click.pass_context
def share_add(ctx, project, usernames, permissions):
"""Add permissions to [users] to project"""
mc = ctx.obj["client"]
if mc is None:
return
usernames = list(usernames)
mc.add_user_permissions_to_project(project, usernames, permissions)


@cli.command()
@click.argument("project")
@click.argument("usernames", nargs=-1)
@click.pass_context
def share_remove(ctx, project, usernames):
"""Remove [users] permissions from project"""
mc = ctx.obj["client"]
if mc is None:
return
usernames = list(usernames)
mc.remove_user_permissions_from_project(project, usernames)


@cli.command()
@click.argument("project")
@click.pass_context
def share(ctx, project):
"""Fetch permissions to project"""
mc = ctx.obj["client"]
if mc is None:
return
access_list = mc.project_user_permissions(project)

for username in access_list.get("owners"):
click.echo("{:20}\t{:20}".format(username, "owner"))
for username in access_list.get("writers"):
if username not in access_list.get("owners"):
click.echo("{:20}\t{:20}".format(username, "writer"))
for username in access_list.get("readers"):
if username not in access_list.get("writers"):
click.echo("{:20}\t{:20}".format(username, "reader"))


@cli.command()
@click.argument("filepath")
@click.argument("output")
Expand Down
54 changes: 54 additions & 0 deletions mergin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,60 @@ def set_project_access(self, project_path, access):
detail = f"Project path: {project_path}"
raise ClientError(str(e), detail)

def add_user_permissions_to_project(self, project_path, usernames, permission_level):
"""
Add specified permissions to specified users to project
:param project_path: project full name (<namespace>/<name>)
:param usernames: list of usernames to be granted specified permission level
:param permission_level: string (reader, writer, owner)
"""
if permission_level not in ["owner", "reader", "writer"]:
raise ClientError("Unsupported permission level")

project_info = self.project_info(project_path)
access = project_info.get('access')
for name in usernames:
if permission_level == "owner":
access.get("ownersnames").append(name)
if permission_level == "writer" or permission_level == "owner":
access.get("writersnames").append(name)
if permission_level == "writer" or permission_level == "owner" or permission_level == "reader":
access.get("readersnames").append(name)
self.set_project_access(project_path, access)

def remove_user_permissions_from_project(self, project_path, usernames):
"""
Removes specified users from project
:param project_path: project full name (<namespace>/<name>)
:param usernames: list of usernames to be granted specified permission level
"""
project_info = self.project_info(project_path)
access = project_info.get('access')
for name in usernames:
if name in access.get("ownersnames"):
access.get("ownersnames").remove(name)
if name in access.get("writersnames"):
access.get("writersnames").remove(name)
if name in access.get("readersnames"):
access.get("readersnames").remove(name)
self.set_project_access(project_path, access)

def project_user_permissions(self, project_path):
"""
Returns permissions for project
:param project_path: project full name (<namespace>/<name>)
:return dict("owners": list(usernames),
"writers": list(usernames),
"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")
return result

def push_project(self, directory):
"""
Upload local changes to the repository.
Expand Down
15 changes: 10 additions & 5 deletions mergin/client_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ def upload_blocking(self, mc, mp):
resp_dict = json.load(resp)
mp.log.debug(f"Upload finished: {self.file_path}")
if not (resp_dict['size'] == len(data) and resp_dict['checksum'] == checksum.hexdigest()):
mc.post("/v1/project/push/cancel/{}".format(self.transaction_id))
try:
mc.post("/v1/project/push/cancel/{}".format(self.transaction_id))
except ClientError:
pass
raise ClientError("Mismatch between uploaded file chunk {} and local one".format(self.chunk_id))


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

job.mp.metadata = {
Expand Down Expand Up @@ -293,7 +298,7 @@ def push_project_cancel(job):
job.executor.shutdown(wait=True)
try:
resp_cancel = job.mc.post("/v1/project/push/cancel/%s" % job.transaction_id)
job.server_resp = json.load(resp_cancel)
job.server_resp = resp_cancel.msg
except ClientError as err:
job.mp.log.error("--- push cancelling failed! " + str(err))
raise err
Expand Down
2 changes: 1 addition & 1 deletion mergin/merginproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
# python paths.
try:
from .deps import pygeodiff
except ImportError:
except (ImportError, ModuleNotFoundError):
import pygeodiff


Expand Down
37 changes: 37 additions & 0 deletions mergin/test/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,43 @@ def test_download_diffs(mc):
assert "Available versions: [1, 2, 3, 4]" in str(e.value)


def test_modify_project_permissions(mc):
test_project = 'test_project'
project = API_USER + '/' + test_project
project_dir = os.path.join(TMP_DIR, test_project)
download_dir = os.path.join(TMP_DIR, 'download', test_project)

cleanup(mc, project, [project_dir, download_dir])
# prepare local project
shutil.copytree(TEST_DATA_DIR, project_dir)

# create remote project
mc.create_project_and_push(test_project, directory=project_dir)

# check basic metadata about created project
project_info = mc.project_info(project)
assert project_info['version'] == 'v1'
assert project_info['name'] == test_project
assert project_info['namespace'] == API_USER

permissions = mc.project_user_permissions(project)
assert permissions["owners"] == [API_USER]
assert permissions["writers"] == [API_USER]
assert permissions["readers"] == [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}

mc.remove_user_permissions_from_project(project, [API_USER2])
permissions = mc.project_user_permissions(project)
assert permissions["owners"] == [API_USER]
assert permissions["writers"] == [API_USER]
assert permissions["readers"] == [API_USER]


def _use_wal(db_file):
""" Ensures that sqlite database is using WAL journal mode """
con = sqlite3.connect(db_file)
Expand Down