Skip to content

Commit

Permalink
Merge pull request #348 from MerginMaps/fe_get_project_collaborators
Browse files Browse the repository at this point in the history
Use v2 api to get project collaborators and their project permission
  • Loading branch information
MarcelGeo authored Jan 7, 2025
2 parents e84681e + 6b1ac37 commit f80c82b
Show file tree
Hide file tree
Showing 13 changed files with 129 additions and 177 deletions.
4 changes: 4 additions & 0 deletions server/mergin/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ def unset_role(self, user_id: int) -> None:

def get_member(self, user_id: int) -> Optional[ProjectMember]:
"""Get project member"""
from .permissions import ProjectPermissions

member = self._member(user_id)
if member:
return ProjectMember(
Expand All @@ -291,6 +293,7 @@ def get_member(self, user_id: int) -> Optional[ProjectMember]:
email=member.user.email,
project_role=ProjectRole(member.role),
workspace_role=self.workspace.get_user_role(member.user),
role=ProjectPermissions.get_user_project_role(self, member.user),
)

def members_by_role(self, role: ProjectRole) -> List[int]:
Expand Down Expand Up @@ -350,6 +353,7 @@ class ProjectMember:
username: str
workspace_role: WorkspaceRole
project_role: Optional[ProjectRole]
role: ProjectRole


@dataclass
Expand Down
30 changes: 13 additions & 17 deletions server/mergin/sync/private_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ paths:
required: true
schema:
type: string
# // Kept for EE (collaborators + invitation) access, TODO: remove when a separate invitation endpoint is implemented
get:
tags:
- project
Expand All @@ -350,34 +351,29 @@ paths:
"404":
$ref: "#/components/responses/NotFoundResp"
x-openapi-router-controller: mergin.sync.private_api_controller
/project/{id}/public:
parameters:
- name: id
in: path
description: Project uuid
required: true
schema:
type: string
patch:
summary: Update direct project access (sharing)
operationId: update_project_access
summary: Update public project flag
operationId: update_project_public_flag
requestBody:
description: Request data
required: true
content:
application/json:
schema:
type: object
properties:
user_id:
type: integer
public:
type: boolean
nullable: true
role:
type: string
enum:
- owner
- writer
- editor
- reader
- none
example: writer
responses:
"200":
$ref: "#/components/schemas/ProjectAccessUpdated"
"204":
description: OK
"400":
$ref: "#/components/responses/BadStatusResp"
"401":
Expand Down
20 changes: 4 additions & 16 deletions server/mergin/sync/private_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,28 +303,16 @@ def unsubscribe_project(id): # pylint: disable=W0612


@auth_required
def update_project_access(id: str):
"""Modify shared project access
def update_project_public_flag(id: str):
"""Modify the project's public flag
:param id: Project uuid
"""
project = require_project_by_uuid(id, ProjectPermissions.Update)

if "public" in request.json:
project.public = request.json["public"]

if "user_id" in request.json and "role" in request.json:
user = User.query.filter_by(
id=request.json["user_id"], active=True
).first_or_404("User does not exist")

if request.json["role"] == "none":
project.unset_role(user.id)
else:
project.set_role(user.id, ProjectRole(request.json["role"]))
project_access_granted.send(project, user_id=user.id)
project.public = request.json.get("public", False)
db.session.commit()
return ProjectAccessSchema().dump(project), 200
return NoContent, 204


@auth_required
Expand Down
7 changes: 7 additions & 0 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@ components:
- reader
- guest
example: writer
Role:
allOf:
- $ref: '#/components/schemas/ProjectRole'
nullable: false
description: combination of workspace role and project role
ProjectMember:
type: object
properties:
Expand All @@ -282,3 +287,5 @@ components:
$ref: '#/components/schemas/WorkspaceRole'
project_role:
$ref: '#/components/schemas/ProjectRole'
role:
$ref: '#/components/schemas/Role'
1 change: 1 addition & 0 deletions server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def get_project_collaborators(id):
email=user.email,
project_role=project_role,
workspace_role=workspace_role,
role=ProjectPermissions.get_user_project_role(project, user),
)
)

Expand Down
7 changes: 4 additions & 3 deletions server/mergin/sync/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,11 @@ def _deserialize(self, value, attr, data, **kwargs):
class ProjectAccessDetailSchema(Schema):
id = StrOrInt()
email = fields.String()
role = fields.String()
role = fields.Enum(enum=ProjectRole, by_value=True)
username = fields.String()
name = fields.String()
workspace_role = fields.String()
project_role = fields.String()
project_role = fields.Enum(enum=ProjectRole, by_value=True)
workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True)
type = fields.String()
invitation = fields.Nested(ProjectInvitationAccessSchema())

Expand Down Expand Up @@ -405,3 +405,4 @@ class ProjectMemberSchema(Schema):
email = fields.Email()
project_role = fields.Enum(enum=ProjectRole, by_value=True)
workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True)
role = fields.Enum(enum=ProjectRole, by_value=True)
1 change: 1 addition & 0 deletions server/mergin/sync/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ def access_requests_query():
"""Project access base query"""
return AccessRequest.query.join(Project)

# not used in CE, TODO: remove together with EE when it's replaced there
def project_access(self, project: Project) -> List[ProjectAccessDetail]:
"""
Project access users overview
Expand Down
120 changes: 3 additions & 117 deletions server/mergin/tests/test_private_project_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,72 +333,16 @@ def test_template_projects(client):


def test_update_project_access(client, diff_project):
url = f"/app/project/{diff_project.id}/access"
url = f"/app/project/{diff_project.id}/public"
original_creator_id = diff_project.creator.id
# create user and grant him write access
user = add_user("reader", "reader")
assert not diff_project.get_role(user.id)

data = {"user_id": user.id, "role": "none"}
# nothing happens
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert not diff_project.get_role(user.id)

# grant read access
data["role"] = "reader"
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert diff_project.get_role(user.id) is ProjectRole.READER

# grant editor access
data["role"] = "editor"
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert diff_project.get_role(user.id) is ProjectRole.EDITOR

# change to write access
data["role"] = "writer"
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert diff_project.get_role(user.id) is ProjectRole.WRITER

# downgrade to read access
data["role"] = "reader"
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert diff_project.get_role(user.id) is ProjectRole.READER

# remove access
data["role"] = "none"
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert not diff_project.get_role(user.id)
data = {}

# update public parameter => public: True
data["public"] = True
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 200
assert resp.status_code == 204
assert diff_project.public == True

# access of project creator can be removed
data["user_id"] = diff_project.creator_id
resp = client.patch(
f"/app/project/{diff_project.id}/access",
headers=json_headers,
data=json.dumps(data),
)
assert resp.status_code == 200
db.session.rollback()
assert not diff_project.get_role(user.id)
assert diff_project.creator_id == original_creator_id

# try to grant access to inaccessible user
data = {"user_id": 100, "role": "reader"}
# nothing happens
resp = client.patch(url, headers=json_headers, data=json.dumps(data))
assert resp.status_code == 404


def test_restore_project(client, diff_project):
"""Test delete project by user and restore by admin"""
Expand Down Expand Up @@ -474,61 +418,3 @@ def test_admin_project_list(client):
p.delete()
resp = client.get("/app/admin/projects?page=1&per_page=15&like=mergin")
assert len(resp.json["items"]) == 14


def test_get_project_access(client):
workspace = create_workspace()
user = User.query.filter(User.username == "mergin").first()
project = create_project("test-project", workspace, user)
url = f"/app/project/{project.id}/access"
users = []
for i in range(5):
users.append(add_user(str(i), str(i)))
Configuration.GLOBAL_ADMIN = False
Configuration.GLOBAL_WRITE = False
Configuration.GLOBAL_READ = False
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 1
assert resp.json[0]["role"] == "owner"
project.set_role(users[0].id, ProjectRole.OWNER)
project.set_role(users[1].id, ProjectRole.WRITER)
project.set_role(users[2].id, ProjectRole.READER)
db.session.commit()
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 4
assert sum(map(lambda x: int(x["role"] == "owner"), resp.json)) == 2
assert sum(map(lambda x: int(x["role"] == "writer"), resp.json)) == 1
assert sum(map(lambda x: int(x["role"] == "reader"), resp.json)) == 1
# user3 does not have access to the project
assert not any(users[3].email == access["email"] for access in resp.json)
assert any(users[2].email == access["email"] for access in resp.json)
Configuration.GLOBAL_READ = True
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 6
assert sum(map(lambda x: int(x["role"] == "owner"), resp.json)) == 2
assert sum(map(lambda x: int(x["role"] == "writer"), resp.json)) == 1
assert sum(map(lambda x: int(x["role"] == "reader"), resp.json)) == 3
Configuration.GLOBAL_WRITE = True
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 6
assert sum(map(lambda x: int(x["role"] == "owner"), resp.json)) == 2
assert sum(map(lambda x: int(x["role"] == "writer"), resp.json)) == 4
assert sum(map(lambda x: int(x["role"] == "reader"), resp.json)) == 0
Configuration.GLOBAL_ADMIN = True
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 6
assert sum(map(lambda x: int(x["role"] == "owner"), resp.json)) == 6
assert sum(map(lambda x: int(x["role"] == "writer"), resp.json)) == 0
assert sum(map(lambda x: int(x["role"] == "reader"), resp.json)) == 0
# pretend a user was deleted to test that api can handle it
users[3].inactivate()
users[3].anonymize()
resp = client.get(url)
assert resp.status_code == 200
assert len(resp.json) == 5
assert sum(map(lambda x: int(x["role"] == "owner"), resp.json)) == 5
3 changes: 1 addition & 2 deletions web-app/packages/lib/src/common/permission_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,7 @@ export function isAtLeastGlobalRole(
roleName: ProjectRoleName,
globalRole: GlobalRole
): boolean {
// We have also none role, so we need to add 1 to the global role
return PROJECT_ROLE_BY_NAME[roleName] >= globalRole + 1
return PROJECT_ROLE_BY_NAME[roleName] >= globalRole
}

export function getProjectRoleNameValues(): DropdownOption<ProjectRoleName>[] {
Expand Down
Loading

0 comments on commit f80c82b

Please sign in to comment.