Skip to content

Commit 4500cf5

Browse files
committed
Create v2 project list endpoint
1 parent 6685b21 commit 4500cf5

File tree

3 files changed

+222
-5
lines changed

3 files changed

+222
-5
lines changed

server/mergin/sync/public_api_v2.yaml

Lines changed: 93 additions & 1 deletion
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 e.g. "name ASC,udpdated DESC"
397+
required: false
398+
schema:
399+
type: string
400+
example: name_asc,updated_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,7 +602,7 @@ components:
528602
$ref: "#/components/schemas/ProjectRole"
529603
role:
530604
$ref: "#/components/schemas/Role"
531-
ProjectDetail:
605+
Project:
532606
type: object
533607
required:
534608
- id
@@ -594,6 +668,24 @@ components:
594668
format: date-time
595669
description: File modification timestamp
596670
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
597689
File:
598690
type: object
599691
description: Project file metadata

server/mergin/sync/public_api_v2_controller.py

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

server/mergin/tests/test_public_api_v2.py

Lines changed: 81 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,81 @@ 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(url + f"?page={page}&per_page={per_page}&q=1")
632+
assert response.json["count"] == 2
633+
assert len(response.json["projects"]) == 2
634+
assert response.json["projects"][1]["name"] == "project_10"
635+
# name search - specific result
636+
project_name = "project_4"
637+
response = client.get(url + f"?page={page}&per_page={per_page}&q={project_name}")
638+
assert response.json["projects"][0]["name"] == project_name
639+
640+
# no permissions to workspace
641+
user2 = add_user("user", "password")
642+
login(client, user2.username, "password")
643+
Configuration.GLOBAL_READ = 0
644+
Configuration.GLOBAL_WRITE = 0
645+
Configuration.GLOBAL_ADMIN = 0
646+
resp = client.get(url + "?page=1&per_page=10")
647+
assert resp.status_code == 200
648+
assert resp.json["count"] == 0
649+
650+
# no existing workspace
651+
assert (
652+
client.get("/v1/workspace/1234/projects?page=1&per_page=10").status_code == 404
653+
)
654+
655+
# project shared directly
656+
p = Project.query.filter_by(workspace_id=test_workspace.id).first()
657+
p.set_role(user2.id, ProjectRole.READER)
658+
db.session.commit()
659+
resp = client.get(url + "?page=1&per_page=10")
660+
resp_data = json.loads(resp.data)
661+
assert resp_data["count"] == 1
662+
assert resp_data["projects"][0]["name"] == p.name
663+
664+
# deactivate project
665+
p.removed_at = datetime.utcnow()
666+
db.session.commit()
667+
resp = client.get(url + "?page=1&per_page=10")
668+
assert resp.json["count"] == 0
669+
670+
# add user as a reader
671+
Configuration.GLOBAL_READ = 1
672+
db.session.commit()
673+
resp = client.get(url + "?page=1&per_page=10")
674+
assert p.name not in [proj["name"] for proj in resp.json["projects"]]
675+
assert resp.json["count"] == 10
676+
677+
# logout
678+
logout(client)
679+
assert client.get(url + "?page=1&per_page=10").status_code == 401

0 commit comments

Comments
 (0)