Skip to content

Commit a792beb

Browse files
authored
Merge pull request #558 from MerginMaps/v2_get_ws_projects
V2 list projects endpoint
2 parents 0b99238 + ffc541f commit a792beb

File tree

4 files changed

+243
-10
lines changed

4 files changed

+243
-10
lines changed

server/mergin/sync/public_api_v2.yaml

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,73 @@ paths:
367367
$ref: "#/components/schemas/ProjectLocked"
368368

369369
x-openapi-router-controller: mergin.sync.public_api_v2_controller
370+
/workspaces/{workspace_id}/projects:
371+
get:
372+
tags:
373+
- workspace
374+
summary: List projects in the workspace
375+
operationId: list_workspace_projects
376+
parameters:
377+
- $ref: '#/components/parameters/WorkspaceId'
378+
- name: page
379+
in: query
380+
description: page number
381+
required: true
382+
schema:
383+
type: integer
384+
minimum: 1
385+
example: 1
386+
- name: per_page
387+
in: query
388+
description: Number of results per page
389+
required: true
390+
schema:
391+
type: integer
392+
maximum: 50
393+
example: 50
394+
- name: order_params
395+
in: query
396+
description: Sorting fields
397+
required: false
398+
schema:
399+
type: string
400+
example: name ASC, expire DESC
401+
- name: q
402+
in: query
403+
description: Filter by name with ilike pattern
404+
required: false
405+
schema:
406+
type: string
407+
example: my-survey
408+
responses:
409+
"200":
410+
description: List of workspace projects that match the query limited to 50
411+
content:
412+
application/json:
413+
schema:
414+
type: object
415+
properties:
416+
page:
417+
type: integer
418+
example: 1
419+
per_page:
420+
type: integer
421+
example: 20
422+
count:
423+
type: integer
424+
example: 10
425+
projects:
426+
type: array
427+
maxItems: 50
428+
items:
429+
$ref: "#/components/schemas/Project"
430+
"401":
431+
$ref: "#/components/responses/Unauthorized"
432+
"403":
433+
$ref: "#/components/responses/Forbidden"
434+
"404":
435+
$ref: "#/components/responses/NotFound"
436+
x-openapi-router-controller: mergin.sync.public_api_v2_controller
370437
components:
371438
responses:
372439
NoContent:
@@ -393,6 +460,13 @@ components:
393460
type: string
394461
format: uuid
395462
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
463+
WorkspaceId:
464+
name: workspace_id
465+
in: path
466+
description: Workspace id
467+
required: true
468+
schema:
469+
type: integer
396470
schemas:
397471
# Errors
398472
CustomError:
@@ -528,11 +602,7 @@ components:
528602
$ref: "#/components/schemas/ProjectRole"
529603
role:
530604
$ref: "#/components/schemas/Role"
531-
name:
532-
nullable: true
533-
type: string
534-
example: John Doe
535-
ProjectDetail:
605+
Project:
536606
type: object
537607
required:
538608
- id
@@ -598,6 +668,24 @@ components:
598668
format: date-time
599669
description: File modification timestamp
600670
example: 2024-11-19T13:50:00Z
671+
ProjectDetail:
672+
allOf:
673+
- $ref: "#/components/schemas/Project"
674+
- type: object
675+
properties:
676+
files:
677+
type: array
678+
description: List of files in the project
679+
items:
680+
allOf:
681+
- $ref: "#/components/schemas/File"
682+
- type: object
683+
properties:
684+
mtime:
685+
type: string
686+
format: date-time
687+
description: File modification timestamp
688+
example: 2024-11-19T13:50:00Z
601689
File:
602690
type: object
603691
description: Project file metadata

server/mergin/sync/public_api_v2_controller.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,16 @@
4040
project_version_created,
4141
push_finished,
4242
)
43-
from .permissions import ProjectPermissions, require_project_by_uuid
43+
from .permissions import ProjectPermissions, require_project_by_uuid, projects_query
4444
from .public_api_controller import catch_sync_failure
4545
from .schemas import (
4646
ProjectMemberSchema,
4747
UploadChunkSchema,
48-
ProjectSchema,
4948
)
5049
from .storages.disk import move_to_tmp, save_to_file
5150
from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location
5251
from .workspace import WorkspaceRole
52+
from ..utils import parse_order_params
5353

5454

5555
@auth_required
@@ -397,3 +397,51 @@ def upload_chunk(id: str):
397397
UploadChunkSchema().dump({"id": chunk_id, "valid_until": valid_until}),
398398
200,
399399
)
400+
401+
402+
@auth_required
403+
def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=None):
404+
"""Paginate over workspace projects with optional filtering.
405+
406+
:param workspace_id: ID of the workspace to list projects from
407+
:param workspace_id: int
408+
:param page: page number
409+
:type page: int
410+
:param per_page: Number of results per page
411+
:type per_page: int
412+
:param order_params: Sorting fields e.g. "name ASC,updated DESC"
413+
:type order_params: str
414+
:param q: Filter by name with ilike pattern
415+
:type q: str
416+
417+
:rtype: Dict[str: List[Project], str: Integer, str: Integer, str: Integer]
418+
"""
419+
ws = current_app.ws_handler.get(workspace_id)
420+
if not (ws and ws.is_active):
421+
abort(404, "Workspace not found")
422+
423+
if ws.user_has_permissions(current_user, "read"):
424+
# regular members can list all projects
425+
projects = Project.query.filter_by(workspace_id=ws.id).filter(
426+
Project.removed_at.is_(None)
427+
)
428+
elif ws.user_has_permissions(current_user, "guest"):
429+
# guest can list only explicitly shared projects
430+
projects = projects_query(
431+
ProjectPermissions.Read, as_admin=False, public=False
432+
).filter(Project.workspace_id == ws.id)
433+
else:
434+
abort(403, "You do not have permissions to workspace")
435+
436+
if q:
437+
projects = projects.filter(Project.name.ilike(f"%{q}%"))
438+
439+
if order_params:
440+
order_by_params = parse_order_params(Project, order_params)
441+
projects = projects.order_by(*order_by_params)
442+
443+
result = projects.paginate(page, per_page).items
444+
total = projects.paginate(page, per_page).total
445+
446+
data = ProjectSchemaV2(many=True).dump(result)
447+
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200

server/mergin/sync/workspace.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ def user_has_permissions(self, user, permissions):
7777
if role is WorkspaceRole.OWNER:
7878
return True
7979

80-
if permissions == "read":
80+
if permissions == "guest":
81+
return role == WorkspaceRole.GUEST
82+
elif permissions == "read":
8183
return role >= WorkspaceRole.READER
8284
elif permissions == "edit":
8385
return role >= WorkspaceRole.EDITOR

server/mergin/tests/test_public_api_v2.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
44

5-
from mergin.sync.tasks import remove_transaction_chunks, remove_unused_chunks
5+
from mergin.sync.tasks import remove_transaction_chunks
66
from . import DEFAULT_USER
77
from .utils import (
88
add_user,
@@ -22,10 +22,10 @@
2222
from sqlalchemy.exc import IntegrityError
2323
import pytest
2424
from datetime import datetime, timedelta, timezone
25+
import json
2526

2627
from mergin.app import db
2728
from mergin.config import Configuration
28-
from mergin.sync.config import Configuration as SyncConfiguration
2929
from mergin.sync.errors import (
3030
BigChunkError,
3131
ProjectLocked,
@@ -51,6 +51,7 @@
5151
_get_changes_with_diff_0_size,
5252
_get_changes_without_added,
5353
)
54+
from ..sync.interfaces import WorkspaceRole
5455

5556

5657
def test_schedule_delete_project(client):
@@ -598,3 +599,97 @@ def test_full_push(client):
598599
os.path.join(project.storage.project_dir, "v2", test_file["path"])
599600
)
600601
assert not Upload.query.filter_by(project_id=project.id).first()
602+
603+
604+
def test_list_workspace_projects(client):
605+
admin = User.query.filter_by(username=DEFAULT_USER[0]).first()
606+
test_workspace = create_workspace()
607+
url = f"v2/workspaces/{test_workspace.id}/projects"
608+
for i in range(1, 11):
609+
create_project(f"project_{i}", test_workspace, admin)
610+
611+
# missing required query params
612+
assert client.get(url).status_code == 400
613+
614+
# success
615+
page = 1
616+
per_page = 10
617+
response = client.get(url + f"?page={page}&per_page={per_page}")
618+
resp_data = json.loads(response.data)
619+
assert response.status_code == 200
620+
assert resp_data["count"] == 11
621+
assert len(resp_data["projects"]) == per_page
622+
# correct number on the last page
623+
page = 4
624+
per_page = 3
625+
response = client.get(url + f"?page={page}&per_page={per_page}")
626+
assert response.json["count"] == 11
627+
assert len(response.json["projects"]) == 2
628+
# name search - more results
629+
page = 1
630+
per_page = 3
631+
response = client.get(
632+
url + f"?page={page}&per_page={per_page}&q=1&order_params=updated ASC"
633+
)
634+
assert response.json["count"] == 2
635+
assert len(response.json["projects"]) == 2
636+
assert response.json["projects"][1]["name"] == "project_10"
637+
# name search - specific result
638+
project_name = "project_4"
639+
response = client.get(url + f"?page={page}&per_page={per_page}&q={project_name}")
640+
assert response.json["projects"][0]["name"] == project_name
641+
# sorting
642+
response = client.get(
643+
url + f"?page={page}&per_page={per_page}&q=1&order_params=created DESC"
644+
)
645+
assert response.json["projects"][0]["name"] == "project_10"
646+
647+
# no permissions to workspace
648+
user2 = add_user("user", "password")
649+
login(client, user2.username, "password")
650+
with patch.object(
651+
Configuration,
652+
"GLOBAL_READ",
653+
0,
654+
), patch.object(
655+
Configuration,
656+
"GLOBAL_WRITE",
657+
0,
658+
), patch.object(
659+
Configuration,
660+
"GLOBAL_ADMIN",
661+
0,
662+
):
663+
resp = client.get(url + "?page=1&per_page=10")
664+
assert resp.status_code == 200
665+
assert resp.json["count"] == 0
666+
667+
# no existing workspace
668+
assert (
669+
client.get("/v1/workspace/1234/projects?page=1&per_page=10").status_code == 404
670+
)
671+
672+
# project shared directly
673+
p = Project.query.filter_by(workspace_id=test_workspace.id).first()
674+
p.set_role(user2.id, ProjectRole.READER)
675+
db.session.commit()
676+
resp = client.get(url + "?page=1&per_page=10")
677+
resp_data = json.loads(resp.data)
678+
assert resp_data["count"] == 1
679+
assert resp_data["projects"][0]["name"] == p.name
680+
681+
# deactivate project
682+
p.removed_at = datetime.utcnow()
683+
db.session.commit()
684+
resp = client.get(url + "?page=1&per_page=10")
685+
assert resp.json["count"] == 0
686+
687+
# add user as a reader
688+
with patch.object(Configuration, "GLOBAL_READ", 1):
689+
resp = client.get(url + "?page=1&per_page=10")
690+
assert p.name not in [proj["name"] for proj in resp.json["projects"]]
691+
assert resp.json["count"] == 10
692+
693+
# logout
694+
logout(client)
695+
assert client.get(url + "?page=1&per_page=10").status_code == 401

0 commit comments

Comments
 (0)