Skip to content

Commit e3cab08

Browse files
authored
Merge pull request #567 from MerginMaps/api-update-batch-endpoint
Update batch API endpoint, openapi documentation and add batch test
2 parents 85f6e30 + 6964521 commit e3cab08

11 files changed

Lines changed: 331 additions & 9 deletions

LICENSES/CLA-signed-list.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ C/ My company has custom contribution contract with Lutra Consulting Ltd. or I a
2121
* luxusko, 25th August 2023
2222
* jozef-budac, 30th January 2024
2323
* fernandinand, 13th March 2025
24+
* xkello, 26th January 2026

server/mergin/sync/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,5 @@ class Configuration(object):
7878
EXCLUDED_CLONE_FILENAMES = config(
7979
"EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv()
8080
)
81+
# max batch size for fetch projects in batch endpoint
82+
MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int)

server/mergin/sync/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,8 @@ def to_dict(self) -> Dict:
9595
class BigChunkError(ResponseError):
9696
code = "BigChunkError"
9797
detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB"
98+
99+
100+
class BatchLimitError(ResponseError):
101+
code = "BatchLimitExceeded"
102+
detail = f"Batch size exceeds maximum allowed size {Configuration.MAX_BATCH_SIZE}"

server/mergin/sync/permissions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,29 @@ def require_project_by_uuid(
248248
return project
249249

250250

251+
def check_project_permissions(
252+
project: Project, permission: ProjectPermissions
253+
) -> int | None:
254+
"""Check project permissions and return appropriate HTTP error code if check fails.
255+
:param project: project
256+
:type project: Project
257+
:param permission: permission to check
258+
:type permission: ProjectPermissions
259+
:return: HTTP error code if permission check fails, None otherwise
260+
:rtype: int | None
261+
"""
262+
263+
if not permission.check(project, current_user):
264+
# logged in - NO, have acccess - NONE, public project - NO
265+
if current_user.is_anonymous:
266+
# we don't want to tell anonymous user if a private project exists
267+
return 404
268+
# logged in - YES, have access - NO, public project - NO
269+
return 403
270+
271+
return None
272+
273+
251274
def get_upload(transaction_id):
252275
upload = Upload.query.get_or_404(transaction_id)
253276
# upload to 'removed' projects is forbidden

server/mergin/sync/project_handler.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,13 @@ def get_email_receivers(self, project: Project) -> List[User]:
2828
)
2929
.all()
3030
)
31+
32+
@staticmethod
33+
def get_projects_by_uuids(uuids: List[str]) -> [Project]:
34+
"""Gets non-deleted projects"""
35+
return (
36+
Project.query.filter(Project.id.in_(uuids))
37+
.filter(Project.storage_params.isnot(None))
38+
.filter(Project.removed_at.is_(None))
39+
.all()
40+
)

server/mergin/sync/public_api_v2.yaml

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

369369
x-openapi-router-controller: mergin.sync.public_api_v2_controller
370+
371+
/projects/batch:
372+
post:
373+
tags:
374+
- project
375+
summary: Get multiple projects by UUIDs
376+
operationId: list_batch_projects
377+
requestBody:
378+
required: true
379+
content:
380+
application/json:
381+
schema:
382+
type: object
383+
required: [ids]
384+
properties:
385+
ids:
386+
type: array
387+
description: List of project UUIDs to fetch
388+
items:
389+
$ref: "#/components/schemas/ProjectId"
390+
responses:
391+
"200":
392+
description: Projects returned as a list of simple project objects and/or error objects.
393+
content:
394+
application/json:
395+
schema:
396+
type: object
397+
required: [projects]
398+
properties:
399+
projects:
400+
type: array
401+
items:
402+
oneOf:
403+
- $ref: "#/components/schemas/Project"
404+
- $ref: "#/components/schemas/BatchItemError"
405+
"400":
406+
description: Batch limit exceeded or one or more UUIDs were invalid
407+
content:
408+
application/problem+json:
409+
schema:
410+
$ref: "#/components/schemas/CustomError"
411+
"401":
412+
$ref: "#/components/responses/Unauthorized"
413+
"404":
414+
$ref: "#/components/responses/NotFound"
415+
x-openapi-router-controller: mergin.sync.public_api_v2_controller
416+
370417
/workspaces/{workspace_id}/projects:
371418
get:
372419
tags:
@@ -457,9 +504,7 @@ components:
457504
description: UUID of the project
458505
required: true
459506
schema:
460-
type: string
461-
format: uuid
462-
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
507+
$ref: "#/components/schemas/ProjectId"
463508
WorkspaceId:
464509
name: workspace_id
465510
in: path
@@ -468,6 +513,10 @@ components:
468513
schema:
469514
type: integer
470515
schemas:
516+
ProjectId:
517+
type: string
518+
format: uuid
519+
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
471520
# Errors
472521
CustomError:
473522
type: object
@@ -547,6 +596,17 @@ components:
547596
example:
548597
code: UploadError
549598
detail: "Project version could not be created (UploadError)"
599+
BatchItemError:
600+
type: object
601+
properties:
602+
id:
603+
$ref: "#/components/schemas/ProjectId"
604+
error:
605+
type: integer
606+
example: 404
607+
required:
608+
- id
609+
- error
550610
# Data
551611
ProjectRole:
552612
type: string

server/mergin/sync/public_api_v2_controller.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717

1818
from mergin.sync.tasks import remove_transaction_chunks
1919

20-
from .schemas_v2 import ProjectSchema as ProjectSchemaV2
20+
from .schemas_v2 import BatchErrorSchema, ProjectSchema as ProjectSchemaV2
2121
from ..app import db
2222
from ..auth import auth_required
2323
from ..auth.models import User
2424
from .errors import (
2525
AnotherUploadRunning,
26+
BatchLimitError,
2627
BigChunkError,
2728
DataSyncError,
2829
ProjectLocked,
@@ -41,14 +42,25 @@
4142
project_version_created,
4243
push_finished,
4344
)
44-
from .permissions import ProjectPermissions, require_project_by_uuid, projects_query
45+
from .permissions import (
46+
ProjectPermissions,
47+
check_project_permissions,
48+
require_project_by_uuid,
49+
projects_query,
50+
)
4551
from .public_api_controller import catch_sync_failure
4652
from .schemas import (
4753
ProjectMemberSchema,
4854
UploadChunkSchema,
4955
)
5056
from .storages.disk import move_to_tmp, save_to_file
51-
from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location
57+
from .utils import (
58+
get_device_id,
59+
get_ip,
60+
get_user_agent,
61+
get_chunk_location,
62+
is_valid_uuid,
63+
)
5264
from .workspace import WorkspaceRole
5365
from ..utils import parse_order_params, get_schema_fields_map
5466

@@ -457,3 +469,40 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N
457469

458470
data = ProjectSchemaV2(many=True).dump(result)
459471
return jsonify(projects=data, count=total, page=page, per_page=per_page), 200
472+
473+
474+
def list_batch_projects(body):
475+
"""List projects by given list of UUIDs. Limit to 100 projects per request.
476+
477+
:param ids: List of project UUIDs
478+
:type ids: List[str]
479+
:rtype: Dict[str: List[Project]]
480+
"""
481+
ids = list(dict.fromkeys(body.get("ids", [])))
482+
# remove duplicates while preserving the order
483+
max_batch = current_app.config.get("MAX_BATCH_SIZE", 100)
484+
if len(ids) > max_batch:
485+
return BatchLimitError().response(400)
486+
487+
projects = current_app.project_handler.get_projects_by_uuids(ids)
488+
by_id = {str(project.id): project for project in projects}
489+
490+
filtered_projects = []
491+
for uuid in ids:
492+
project = by_id.get(uuid)
493+
494+
if not project:
495+
filtered_projects.append(
496+
BatchErrorSchema().dump({"id": uuid, "error": 404})
497+
)
498+
continue
499+
500+
err = check_project_permissions(project, ProjectPermissions.Read)
501+
if err is not None:
502+
filtered_projects.append(
503+
BatchErrorSchema().dump({"id": uuid, "error": err})
504+
)
505+
else:
506+
filtered_projects.append(ProjectSchemaV2().dump(project))
507+
508+
return jsonify(projects=filtered_projects), 200

server/mergin/sync/schemas_v2.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,8 @@ class Meta:
4646
"workspace",
4747
"role",
4848
)
49+
50+
51+
class BatchErrorSchema(ma.Schema):
52+
id = fields.UUID(required=True)
53+
error = fields.Integer(required=True)

server/mergin/tests/test_permissions.py

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

5+
import pytest
6+
from unittest.mock import patch
57
import datetime
68
from flask_login import AnonymousUserMixin
79

8-
from ..sync.permissions import require_project, ProjectPermissions
9-
from ..sync.models import ProjectRole
10+
from mergin.tests import DEFAULT_USER
11+
12+
from ..sync.permissions import (
13+
require_project,
14+
check_project_permissions,
15+
ProjectPermissions,
16+
)
17+
from ..sync.models import Project, ProjectRole
1018
from ..auth.models import User
1119
from ..app import db
1220
from ..config import Configuration
13-
from .utils import add_user, create_project, create_workspace
21+
from .utils import (
22+
add_user,
23+
create_project,
24+
create_workspace,
25+
login,
26+
logout,
27+
)
1428

1529

1630
def test_project_permissions(client):
@@ -116,3 +130,47 @@ def test_project_permissions(client):
116130
assert ProjectPermissions.All.check(project, user)
117131
assert ProjectPermissions.Edit.check(project, user)
118132
assert ProjectPermissions.get_user_project_role(project, user) == ProjectRole.OWNER
133+
134+
135+
def test_check_project_permissions(client):
136+
"""Test check_project_permissions with various permission scenarios."""
137+
admin = User.query.filter_by(username=DEFAULT_USER[0]).first()
138+
test_workspace = create_workspace()
139+
140+
private_proj = create_project("batch_private", test_workspace, admin)
141+
public_proj = create_project("batch_public", test_workspace, admin)
142+
143+
p = Project.query.get(public_proj.id)
144+
p.public = True
145+
db.session.commit()
146+
147+
priv_proj = Project.query.get(private_proj.id)
148+
pub_proj = Project.query.get(public_proj.id)
149+
150+
# First user with access to both projects
151+
login(client, DEFAULT_USER[0], DEFAULT_USER[1])
152+
153+
with client:
154+
client.get("/")
155+
assert check_project_permissions(priv_proj, ProjectPermissions.Read) is None
156+
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None
157+
158+
# Second user with no access to private project (ensure global perms disabled)
159+
with patch.object(Configuration, "GLOBAL_READ", False), patch.object(
160+
Configuration, "GLOBAL_WRITE", False
161+
), patch.object(Configuration, "GLOBAL_ADMIN", False):
162+
user2 = add_user("user_batch", "password")
163+
login(client, user2.username, "password")
164+
165+
with client:
166+
client.get("/")
167+
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None
168+
assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 403
169+
170+
# Logged-out (anonymous) user
171+
logout(client)
172+
173+
with client:
174+
client.get("/")
175+
assert check_project_permissions(priv_proj, ProjectPermissions.Read) == 404
176+
assert check_project_permissions(pub_proj, ProjectPermissions.Read) is None

server/mergin/tests/test_project_handler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from datetime import datetime
2+
3+
from . import DEFAULT_USER
14
from ..sync.models import Project, ProjectRole
25
from .utils import add_user, create_project, create_workspace
36
from ..sync.project_handler import ProjectHandler
@@ -51,3 +54,26 @@ def test_email_receivers(client):
5154
db.session.commit()
5255
receivers = project_handler.get_email_receivers(project)
5356
assert len(receivers) == 0
57+
58+
59+
def test_get_projects_by_uuids(client):
60+
"""Test getting projects with their UUIDs"""
61+
project_handler = ProjectHandler()
62+
test_workspace = create_workspace()
63+
user = User.query.filter_by(username=DEFAULT_USER[0]).first()
64+
p_found = create_project("p_found", test_workspace, user)
65+
p_removed = create_project("p_removed", test_workspace, user)
66+
p_removed.removed_at = datetime.now()
67+
db.session.commit()
68+
p_other = create_project("p_other", test_workspace, user)
69+
ids = [
70+
str(p_found.id),
71+
str(p_removed.id),
72+
]
73+
74+
projects = project_handler.get_projects_by_uuids(ids)
75+
returned_ids = [str(p.id) for p in projects]
76+
assert str(p_found.id) in returned_ids
77+
assert str(p_removed.id) not in returned_ids
78+
assert str(p_other.id) not in returned_ids
79+
assert len(projects) == 1

0 commit comments

Comments
 (0)