Skip to content
Open
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
94 changes: 93 additions & 1 deletion server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,73 @@ paths:
$ref: "#/components/schemas/ProjectLocked"

x-openapi-router-controller: mergin.sync.public_api_v2_controller
/workspaces/{workspace_id}/projects:
get:
tags:
- workspace
summary: List projects in the workspace
operationId: list_workspace_projects
parameters:
- $ref: '#/components/parameters/WorkspaceId'
- name: page
in: query
description: page number
required: true
schema:
type: integer
minimum: 1
example: 1
- name: per_page
in: query
description: Number of results per page
required: true
schema:
type: integer
maximum: 50
example: 50
- name: order_params
in: query
description: Sorting fields e.g. "name ASC,udpdated DESC"
required: false
schema:
type: string
example: name_asc,updated_desc
- name: q
in: query
description: Filter by name with ilike pattern
required: false
schema:
type: string
example: my-survey
responses:
"200":
description: List of workspace projects that match the query limited to 50
content:
application/json:
schema:
type: object
properties:
page:
type: integer
example: 1
per_page:
type: integer
example: 20
count:
type: integer
example: 10
projects:
type: array
maxItems: 50
items:
$ref: "#/components/schemas/Project"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
x-openapi-router-controller: mergin.sync.public_api_v2_controller
components:
responses:
NoContent:
Expand All @@ -393,6 +460,13 @@ components:
type: string
format: uuid
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
WorkspaceId:
name: workspace_id
in: path
description: Workspace id
required: true
schema:
type: integer
schemas:
# Errors
CustomError:
Expand Down Expand Up @@ -528,7 +602,7 @@ components:
$ref: "#/components/schemas/ProjectRole"
role:
$ref: "#/components/schemas/Role"
ProjectDetail:
Project:
type: object
required:
- id
Expand Down Expand Up @@ -594,6 +668,24 @@ components:
format: date-time
description: File modification timestamp
example: 2024-11-19T13:50:00Z
ProjectDetail:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need new ProjectDetail?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's Project with files

  • in the list project endpoint I want to return Project without files so I separated those two

allOf:
- $ref: "#/components/schemas/Project"
- type: object
properties:
files:
type: array
description: List of files in the project
items:
allOf:
- $ref: "#/components/schemas/File"
- type: object
properties:
mtime:
type: string
format: date-time
description: File modification timestamp
example: 2024-11-19T13:50:00Z
File:
type: object
description: Project file metadata
Expand Down
50 changes: 48 additions & 2 deletions server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@
project_version_created,
push_finished,
)
from .permissions import ProjectPermissions, require_project_by_uuid
from .permissions import ProjectPermissions, require_project_by_uuid, projects_query
from .public_api_controller import catch_sync_failure
from .schemas import (
ProjectMemberSchema,
UploadChunkSchema,
ProjectSchema,
)
from .storages.disk import move_to_tmp, save_to_file
from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location
from .workspace import WorkspaceRole
from ..utils import parse_order_params


@auth_required
Expand Down Expand Up @@ -396,3 +396,49 @@ def upload_chunk(id: str):
UploadChunkSchema().dump({"id": chunk_id, "valid_until": valid_until}),
200,
)


@auth_required
def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=None):
"""Paginate over workspace projects with optional filtering.

:param page: page number
:type page: int
:param per_page: Number of results per page
:type per_page: int
:param order_params: Sorting fields e.g. "name ASC,updated DESC"
:type order_params: str
:param q: Filter by name with ilike pattern
:type q: str

:rtype: Dict[str: List[Project], str: Integer]
"""
ws = current_app.ws_handler.get(workspace_id)
if not (ws and ws.is_active):
abort(404, "Workspace not found")

if ws.user_has_permissions(current_user, "read"):
# regular members can list all projects
projects = Project.query.filter_by(workspace_id=ws.id).filter(
Project.removed_at.is_(None)
)
elif ws.user_has_permissions(current_user, "guest"):
# guest can list only explicitly shared projects
projects = projects_query(
ProjectPermissions.Read, as_admin=False, public=False
).filter(Project.workspace_id == ws.id)
else:
abort(403, "You do not have permissions to workspace")

if q:
projects = projects.filter(Project.name.ilike(f"%{q}%"))

if order_params:
order_by_params = parse_order_params(Project, order_params)
projects = projects.order_by(*order_by_params)

result = projects.paginate(page, per_page).items
total = projects.paginate(page, per_page).total

data = ProjectSchemaV2(many=True).dump(result)
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200
4 changes: 3 additions & 1 deletion server/mergin/sync/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def user_has_permissions(self, user, permissions):
if role is WorkspaceRole.OWNER:
return True

if permissions == "read":
if permissions == "guest":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this? This could not affect rest of endpoints?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO it should be there there in oder to be able to share a specific project with a user when all global roles are disabled
in that case, the user is a guest, and without this update, this method will return false, and the access would be refused

yes, need to be careful about this, I can double-check the implementations of this method

return role >= WorkspaceRole.GUEST
elif permissions == "read":
return role >= WorkspaceRole.READER
elif permissions == "edit":
return role >= WorkspaceRole.EDITOR
Expand Down
83 changes: 81 additions & 2 deletions server/mergin/tests/test_public_api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

from mergin.sync.tasks import remove_transaction_chunks, remove_unused_chunks
from mergin.sync.tasks import remove_transaction_chunks
from . import DEFAULT_USER
from .utils import (
add_user,
Expand All @@ -22,10 +22,10 @@
from sqlalchemy.exc import IntegrityError
import pytest
from datetime import datetime, timedelta, timezone
import json
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json lib is useful for loading response data
resp_data = json.loads(resp.data)
you can examine the response in debug mode - without saving the response to the variable I see a loading values for the object in my IDE, which is inconvenient.


from mergin.app import db
from mergin.config import Configuration
from mergin.sync.config import Configuration as SyncConfiguration
from mergin.sync.errors import (
BigChunkError,
ProjectLocked,
Expand All @@ -51,6 +51,7 @@
_get_changes_with_diff_0_size,
_get_changes_without_added,
)
from ..sync.interfaces import WorkspaceRole


def test_schedule_delete_project(client):
Expand Down Expand Up @@ -598,3 +599,81 @@ def test_full_push(client):
os.path.join(project.storage.project_dir, "v2", test_file["path"])
)
assert not Upload.query.filter_by(project_id=project.id).first()


def test_list_workspace_projects(client):
admin = User.query.filter_by(username=DEFAULT_USER[0]).first()
test_workspace = create_workspace()
url = f"v2/workspaces/{test_workspace.id}/projects"
for i in range(1, 11):
create_project(f"project_{i}", test_workspace, admin)

# missing required query params
assert client.get(url).status_code == 400

# success
page = 1
per_page = 10
response = client.get(url + f"?page={page}&per_page={per_page}")
resp_data = json.loads(response.data)
assert response.status_code == 200
assert resp_data["count"] == 11
assert len(resp_data["projects"]) == per_page
# correct number on the last page
page = 4
per_page = 3
response = client.get(url + f"?page={page}&per_page={per_page}")
assert response.json["count"] == 11
assert len(response.json["projects"]) == 2
# name search - more results
page = 1
per_page = 3
response = client.get(url + f"?page={page}&per_page={per_page}&q=1")
assert response.json["count"] == 2
assert len(response.json["projects"]) == 2
assert response.json["projects"][1]["name"] == "project_10"
# name search - specific result
project_name = "project_4"
response = client.get(url + f"?page={page}&per_page={per_page}&q={project_name}")
assert response.json["projects"][0]["name"] == project_name

# no permissions to workspace
user2 = add_user("user", "password")
login(client, user2.username, "password")
Configuration.GLOBAL_READ = 0
Configuration.GLOBAL_WRITE = 0
Configuration.GLOBAL_ADMIN = 0
resp = client.get(url + "?page=1&per_page=10")
assert resp.status_code == 200
assert resp.json["count"] == 0

# no existing workspace
assert (
client.get("/v1/workspace/1234/projects?page=1&per_page=10").status_code == 404
)

# project shared directly
p = Project.query.filter_by(workspace_id=test_workspace.id).first()
p.set_role(user2.id, ProjectRole.READER)
db.session.commit()
resp = client.get(url + "?page=1&per_page=10")
resp_data = json.loads(resp.data)
assert resp_data["count"] == 1
assert resp_data["projects"][0]["name"] == p.name

# deactivate project
p.removed_at = datetime.utcnow()
db.session.commit()
resp = client.get(url + "?page=1&per_page=10")
assert resp.json["count"] == 0

# add user as a reader
Configuration.GLOBAL_READ = 1
db.session.commit()
resp = client.get(url + "?page=1&per_page=10")
assert p.name not in [proj["name"] for proj in resp.json["projects"]]
assert resp.json["count"] == 10

# logout
logout(client)
assert client.get(url + "?page=1&per_page=10").status_code == 401
Loading