diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index eb635d9a..1d1f92da 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -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: @@ -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: @@ -528,7 +602,7 @@ components: $ref: "#/components/schemas/ProjectRole" role: $ref: "#/components/schemas/Role" - ProjectDetail: + Project: type: object required: - id @@ -594,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 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 9909854b..a2473aaf 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -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 @@ -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 diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 76245ef3..0f776443 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -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 diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 34a5a2a1..c7739046 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -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, @@ -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, @@ -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): @@ -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