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
98 changes: 93 additions & 5 deletions 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
required: false
schema:
type: string
example: name ASC, expire 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,11 +602,7 @@ components:
$ref: "#/components/schemas/ProjectRole"
role:
$ref: "#/components/schemas/Role"
name:
nullable: true
type: string
example: John Doe
ProjectDetail:
Project:
type: object
required:
- id
Expand Down Expand Up @@ -598,6 +668,24 @@ components:
format: date-time
description: File modification timestamp
example: 2024-11-19T13:50:00Z
ProjectDetail:
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
52 changes: 50 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 @@ -397,3 +397,51 @@ 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 workspace_id: ID of the workspace to list projects from
:param workspace_id: int
: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, str: Integer, 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":
return role == WorkspaceRole.GUEST
elif permissions == "read":
return role >= WorkspaceRole.READER
elif permissions == "edit":
return role >= WorkspaceRole.EDITOR
Expand Down
99 changes: 97 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

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,97 @@ 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&order_params=updated ASC"
)
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
# sorting
response = client.get(
url + f"?page={page}&per_page={per_page}&q=1&order_params=created DESC"
)
assert response.json["projects"][0]["name"] == "project_10"

# no permissions to workspace
user2 = add_user("user", "password")
login(client, user2.username, "password")
with patch.object(
Configuration,
"GLOBAL_READ",
0,
), patch.object(
Configuration,
"GLOBAL_WRITE",
0,
), patch.object(
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
with patch.object(Configuration, "GLOBAL_READ", 1):
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