From c118ca94cf3d5a89405283f0460d362ec9ec560d Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 30 Oct 2025 16:37:27 +0100 Subject: [PATCH 1/9] Create endpoint - new marshmallow schema - controller function --- server/mergin/sync/files.py | 2 +- server/mergin/sync/public_api_v2.yaml | 106 ++++++++++++++++++ .../mergin/sync/public_api_v2_controller.py | 20 +++- server/mergin/sync/schemas.py | 37 ++++++ 4 files changed, 162 insertions(+), 3 deletions(-) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index 12b30afe..9f2011f2 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -122,5 +122,5 @@ class ProjectFileSchema(FileSchema): def patch_field(self, data, **kwargs): # drop 'diff' key entirely if empty or None as clients would expect if not data.get("diff"): - data.pop("diff") + data.pop("diff", None) return data diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index 04dbce61..6a277b52 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -76,6 +76,35 @@ paths: "409": $ref: "#/components/responses/Conflict" x-openapi-router-controller: mergin.sync.public_api_v2_controller + get: + tags: + - project + summary: Get project info + operationId: get_project + parameters: + - name: files_at_version + in: query + description: Include list of files at specific version + required: false + schema: + type: string + example: v3 + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectDetail" + "400": + $ref: "#/components/responses/BadRequest" + "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 /projects/{id}/scheduleDelete: post: tags: @@ -287,3 +316,80 @@ components: $ref: "#/components/schemas/ProjectRole" role: $ref: "#/components/schemas/Role" + ProjectDetail: + type: object + required: + - id + - name + - workspace + - role + - version + - created_at + - updated_at + - public + - size + properties: + id: + type: string + description: project uuid + example: c1ae6439-0056-42df-a06d-79cc430dd7df + name: + type: string + example: survey + workspace: + type: object + properties: + id: + type: integer + example: 123 + name: + type: string + example: mergin + role: + $ref: "#/components/schemas/ProjectRole" + version: + type: string + description: latest project version + example: v2 + created_at: + type: string + format: date-time + description: project creation timestamp + example: 2025-10-24T08:27:56Z + updated_at: + type: string + format: date-time + description: last project update timestamp + example: 2025-10-24T08:28:00.279699Z + public: + type: boolean + description: whether the project is public + example: false + size: + type: integer + description: project size in bytes for this version + example: 17092380 + files: + type: array + description: List of files in the project + items: + type: object + properties: + path: + type: string + description: File name including path from project root + example: data/layer.gpkg + mtime: + type: string + format: date-time + description: File modification timestamp + example: 2024-11-19T13:50:00Z + size: + type: integer + description: File size in bytes + example: 1234 + checksum: + type: string + description: File checksum hash + example: 9adb76bf81a34880209040ffe5ee262a090b62ab + diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 7f40c54b..698ce390 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -8,13 +8,14 @@ from flask_login import current_user from mergin.sync.forms import project_name_validation +from .files import ProjectFileSchema -from .schemas import ProjectMemberSchema +from .schemas import ProjectMemberSchema, ProjectSchemaV2 from .workspace import WorkspaceRole from ..app import db from ..auth import auth_required from ..auth.models import User -from .models import Project, ProjectRole, ProjectMember +from .models import Project, ProjectRole, ProjectMember, ProjectVersion from .permissions import ProjectPermissions, require_project_by_uuid @@ -128,3 +129,18 @@ def remove_project_collaborator(id, user_id): project.unset_role(user_id) db.session.commit() return NoContent, 204 + + +def get_project(id, files_at_version=None): + """Get project info. Include list of files at specific version if requested.""" + project = require_project_by_uuid(id, ProjectPermissions.Read) + data = ProjectSchemaV2().dump(project) + if files_at_version: + pv = ProjectVersion.query.filter_by( + project_id=project.id, name=ProjectVersion.from_v_name(files_at_version) + ).first_or_404() + data["files"] = ProjectFileSchema( + only=("path", "mtime", "size", "checksum"), many=True + ).dump(pv.files) + + return data, 200 diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py index 75b6f09e..69ead45e 100644 --- a/server/mergin/sync/schemas.py +++ b/server/mergin/sync/schemas.py @@ -405,3 +405,40 @@ class ProjectMemberSchema(Schema): project_role = fields.Enum(enum=ProjectRole, by_value=True) workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) role = fields.Enum(enum=ProjectRole, by_value=True) + + +class ProjectSchemaV2(ma.SQLAlchemyAutoSchema): + id = fields.UUID() + name = fields.String() + version = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.latest_version)) + public = fields.Boolean() + size = fields.Integer(attribute="disk_usage") + + created_at = DateTimeWithZ(attribute="created") + updated_at = DateTimeWithZ(attribute="updated") + + workspace = fields.Function( + lambda obj: {"id": obj.workspace.id, "name": obj.workspace.name} + ) + role = fields.Method("_role") + + def _role(self, obj): + role = ProjectPermissions.get_user_project_role(obj, current_user) + if not role: + return None + return role.value + + class Meta: + model = Project + load_instance = True + fields = ( + "id", + "name", + "version", + "public", + "size", + "created_at", + "updated_at", + "workspace", + "role", + ) From 6e9f1774f78b284e3c4cd615cc8f4e9833af41e8 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 31 Oct 2025 15:41:57 +0100 Subject: [PATCH 2/9] add tests --- .../mergin/sync/public_api_v2_controller.py | 5 +- server/mergin/sync/schemas.py | 37 --------- server/mergin/sync/schemas_v2.py | 50 ++++++++++++ server/mergin/tests/test_public_api_v2.py | 79 ++++++++++++++++++- 4 files changed, 131 insertions(+), 40 deletions(-) create mode 100644 server/mergin/sync/schemas_v2.py diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 698ce390..96b7f914 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -10,7 +10,8 @@ from mergin.sync.forms import project_name_validation from .files import ProjectFileSchema -from .schemas import ProjectMemberSchema, ProjectSchemaV2 +from .schemas import ProjectMemberSchema +from .schemas_v2 import ProjectSchema from .workspace import WorkspaceRole from ..app import db from ..auth import auth_required @@ -134,7 +135,7 @@ def remove_project_collaborator(id, user_id): def get_project(id, files_at_version=None): """Get project info. Include list of files at specific version if requested.""" project = require_project_by_uuid(id, ProjectPermissions.Read) - data = ProjectSchemaV2().dump(project) + data = ProjectSchema().dump(project) if files_at_version: pv = ProjectVersion.query.filter_by( project_id=project.id, name=ProjectVersion.from_v_name(files_at_version) diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py index 69ead45e..75b6f09e 100644 --- a/server/mergin/sync/schemas.py +++ b/server/mergin/sync/schemas.py @@ -405,40 +405,3 @@ class ProjectMemberSchema(Schema): project_role = fields.Enum(enum=ProjectRole, by_value=True) workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) role = fields.Enum(enum=ProjectRole, by_value=True) - - -class ProjectSchemaV2(ma.SQLAlchemyAutoSchema): - id = fields.UUID() - name = fields.String() - version = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.latest_version)) - public = fields.Boolean() - size = fields.Integer(attribute="disk_usage") - - created_at = DateTimeWithZ(attribute="created") - updated_at = DateTimeWithZ(attribute="updated") - - workspace = fields.Function( - lambda obj: {"id": obj.workspace.id, "name": obj.workspace.name} - ) - role = fields.Method("_role") - - def _role(self, obj): - role = ProjectPermissions.get_user_project_role(obj, current_user) - if not role: - return None - return role.value - - class Meta: - model = Project - load_instance = True - fields = ( - "id", - "name", - "version", - "public", - "size", - "created_at", - "updated_at", - "workspace", - "role", - ) diff --git a/server/mergin/sync/schemas_v2.py b/server/mergin/sync/schemas_v2.py new file mode 100644 index 00000000..668a7e20 --- /dev/null +++ b/server/mergin/sync/schemas_v2.py @@ -0,0 +1,50 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +from marshmallow import fields +from flask_login import current_user + +from ..app import DateTimeWithZ, ma +from .permissions import ProjectPermissions +from .models import ( + Project, + ProjectVersion, +) + + +class ProjectSchema(ma.SQLAlchemyAutoSchema): + id = fields.UUID() + name = fields.String() + version = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.latest_version)) + public = fields.Boolean() + size = fields.Integer(attribute="disk_usage") + + created_at = DateTimeWithZ(attribute="created") + updated_at = DateTimeWithZ(attribute="updated") + + workspace = fields.Function( + lambda obj: {"id": obj.workspace.id, "name": obj.workspace.name} + ) + role = fields.Method("_role") + + def _role(self, obj): + role = ProjectPermissions.get_user_project_role(obj, current_user) + if not role: + return None + return role.value + + class Meta: + model = Project + load_instance = True + fields = ( + "id", + "name", + "version", + "public", + "size", + "created_at", + "updated_at", + "workspace", + "role", + ) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 2d88d652..f39f142b 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1,11 +1,22 @@ # Copyright (C) Lutra Consulting Limited # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from .utils import add_user +from datetime import datetime + +from . import DEFAULT_USER +from .utils import ( + add_user, + login, + login_as_admin, + create_workspace, + create_project, + upload_file_to_project, +) from ..app import db from mergin.sync.models import Project from tests import test_project, test_workspace_id +from ..auth.models import User from ..config import Configuration from ..sync.models import ProjectRole @@ -126,3 +137,69 @@ def test_project_members(client): # access provided by workspace role cannot be removed directly response = client.delete(url + f"/{user.id}") assert response.status_code == 404 + + +def test_get_project(client): + """Test get project info endpoint""" + admin = User.query.filter_by(username=DEFAULT_USER[0]).first() + test_workspace = create_workspace() + project = create_project("new_project", test_workspace, admin) + add_user("test_user", "ilovemergin") + login(client, "test_user", "ilovemergin") + # lack of permissions + response = client.get(f"v2/projects/{project.id}") + assert response.status_code == 403 + # access public project + project.public = True + db.session.commit() + response = client.get(f"v2/projects/{project.id}") + assert response.status_code == 200 + assert response.json["public"] is True + # project scheduled for deletion + login_as_admin(client) + project.public = False + project.removed_at = datetime.utcnow() + db.session.commit() + response = client.get(f"v2/projects/{project.id}") + assert response.status_code == 404 + # success + project.removed_at = None + db.session.commit() + response = client.get(f"v2/projects/{project.id}") + assert response.status_code == 200 + expected_keys = { + "id", + "name", + "workspace", + "role", + "version", + "created_at", + "updated_at", + "public", + "size", + } + assert expected_keys == response.json.keys() + # create new versions + files = ["test.txt", "test3.txt", "test.qgs"] + for file in files: + upload_file_to_project(project, file, client) + # project version does not exist + response = client.get( + f"v2/projects/{project.id}?files_at_version=v{project.latest_version+1}" + ) + assert response.status_code == 404 + # files + response = client.get( + f"v2/projects/{project.id}?files_at_version=v{project.latest_version-2}" + ) + assert response.status_code == 200 + assert len(response.json["files"]) == 1 + assert any(resp_files["path"] == files[0] for resp_files in response.json["files"]) + assert not any( + resp_files["path"] == files[1] for resp_files in response.json["files"] + ) + response = client.get( + f"v2/projects/{project.id}?files_at_version=v{project.latest_version}" + ) + assert len(response.json["files"]) == 3 + assert {f["path"] for f in response.json["files"]} == set(files) From 39129f22e3f9a913ed3716132bb8f85bea96dd98 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 3 Nov 2025 09:30:51 +0100 Subject: [PATCH 3/9] Fix test helpers order to give time to manifest --- server/mergin/tests/test_public_api_v2.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index f39f142b..6272edb3 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1,6 +1,7 @@ # Copyright (C) Lutra Consulting Limited # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +import time from datetime import datetime from . import DEFAULT_USER @@ -141,11 +142,11 @@ def test_project_members(client): def test_get_project(client): """Test get project info endpoint""" + add_user("test_user", "ilovemergin") + login(client, "test_user", "ilovemergin") admin = User.query.filter_by(username=DEFAULT_USER[0]).first() test_workspace = create_workspace() project = create_project("new_project", test_workspace, admin) - add_user("test_user", "ilovemergin") - login(client, "test_user", "ilovemergin") # lack of permissions response = client.get(f"v2/projects/{project.id}") assert response.status_code == 403 From 889a73c7e4023d2a080e91bf104afdc3516b532a Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Tue, 4 Nov 2025 14:28:40 +0100 Subject: [PATCH 4/9] improve tests --- server/mergin/tests/test_public_api_v2.py | 5 ++--- server/mergin/tests/utils.py | 6 ++++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 6272edb3..d23712f2 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -7,7 +7,7 @@ from . import DEFAULT_USER from .utils import ( add_user, - login, + logout, login_as_admin, create_workspace, create_project, @@ -142,11 +142,10 @@ def test_project_members(client): def test_get_project(client): """Test get project info endpoint""" - add_user("test_user", "ilovemergin") - login(client, "test_user", "ilovemergin") admin = User.query.filter_by(username=DEFAULT_USER[0]).first() test_workspace = create_workspace() project = create_project("new_project", test_workspace, admin) + logout(client) # lack of permissions response = client.get(f"v2/projects/{project.id}") assert response.status_code == 403 diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 94fc033f..02042da0 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -387,3 +387,9 @@ def modify_file_times(path, time: datetime, accessed=True, modified=True): mtime = epoch_time if modified else file_stat.st_mtime os.utime(path, (atime, mtime)) + + +def logout(client): + """Test helper to log out the client""" + resp = client.get(url_for("/.mergin_auth_controller_logout")) + assert resp.status_code == 200 From e8acc1d76f2ee60c59a9e949d76f9391276b2b31 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Tue, 4 Nov 2025 14:49:40 +0100 Subject: [PATCH 5/9] fix json encoding --- server/mergin/sync/public_api_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index a82c5768..e4c949b8 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -627,7 +627,7 @@ def get_paginated_projects( ) # temporary yield to gevent hub until serialization is fully resolved (#317) data = ProjectListSchema(many=True, context=ctx).dump(result) data = {"projects": data, "count": total} - return data, 200 + return jsonify(data), 200 @auth_required From 5c9b768fd1ab3cfcd8355c163a0389398ae44e3f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 5 Nov 2025 07:43:42 +0100 Subject: [PATCH 6/9] Revert "fix json encoding" This reverts commit e8acc1d76f2ee60c59a9e949d76f9391276b2b31. --- server/mergin/sync/public_api_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index e4c949b8..a82c5768 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -627,7 +627,7 @@ def get_paginated_projects( ) # temporary yield to gevent hub until serialization is fully resolved (#317) data = ProjectListSchema(many=True, context=ctx).dump(result) data = {"projects": data, "count": total} - return jsonify(data), 200 + return data, 200 @auth_required From 830ef0f886c455782d8b5f264b0e9f37a3fb099c Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 7 Nov 2025 12:18:09 +0100 Subject: [PATCH 7/9] address review - survive non-existing project version --- server/mergin/sync/public_api_v2_controller.py | 9 +++++---- server/mergin/sync/schemas_v2.py | 4 +--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 96b7f914..712fe81d 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -139,9 +139,10 @@ def get_project(id, files_at_version=None): if files_at_version: pv = ProjectVersion.query.filter_by( project_id=project.id, name=ProjectVersion.from_v_name(files_at_version) - ).first_or_404() - data["files"] = ProjectFileSchema( - only=("path", "mtime", "size", "checksum"), many=True - ).dump(pv.files) + ).first() + if pv: + data["files"] = ProjectFileSchema( + only=("path", "mtime", "size", "checksum"), many=True + ).dump(pv.files) return data, 200 diff --git a/server/mergin/sync/schemas_v2.py b/server/mergin/sync/schemas_v2.py index 668a7e20..d6b781ee 100644 --- a/server/mergin/sync/schemas_v2.py +++ b/server/mergin/sync/schemas_v2.py @@ -30,9 +30,7 @@ class ProjectSchema(ma.SQLAlchemyAutoSchema): def _role(self, obj): role = ProjectPermissions.get_user_project_role(obj, current_user) - if not role: - return None - return role.value + return role.value if role else None class Meta: model = Project From 6adb3f24ac25c2202ba99a80d6766dc378a0dc3c Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 10 Nov 2025 07:55:09 +0100 Subject: [PATCH 8/9] fix test --- server/mergin/tests/test_public_api_v2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index d23712f2..542736ef 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -187,7 +187,9 @@ def test_get_project(client): response = client.get( f"v2/projects/{project.id}?files_at_version=v{project.latest_version+1}" ) - assert response.status_code == 404 + assert response.status_code == 200 + assert response.json["id"] == str(project.id) + assert "files" not in response.json.keys() # files response = client.get( f"v2/projects/{project.id}?files_at_version=v{project.latest_version-2}" From 072e4bb52ab998084c9b47ed0ff89ed91c9b8398 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 14 Nov 2025 14:07:10 +0100 Subject: [PATCH 9/9] fix schema import --- server/mergin/sync/public_api_v2.yaml | 28 ++++++------------- .../mergin/sync/public_api_v2_controller.py | 4 +-- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index 1f7ef843..bf3db007 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -588,25 +588,15 @@ components: type: array description: List of files in the project items: - type: object - properties: - path: - type: string - description: File name including path from project root - example: data/layer.gpkg - mtime: - type: string - format: date-time - description: File modification timestamp - example: 2024-11-19T13:50:00Z - size: - type: integer - description: File size in bytes - example: 1234 - checksum: - type: string - description: File checksum hash - example: 9adb76bf81a34880209040ffe5ee262a090b62ab + 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 662462b2..27e0355a 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -14,7 +14,7 @@ from marshmallow import ValidationError from sqlalchemy.exc import IntegrityError -from .schemas_v2 import ProjectSchema +from .schemas_v2 import ProjectSchema as ProjectSchemaV2 from ..app import db from ..auth import auth_required from ..auth.models import User @@ -166,7 +166,7 @@ def remove_project_collaborator(id, user_id): def get_project(id, files_at_version=None): """Get project info. Include list of files at specific version if requested.""" project = require_project_by_uuid(id, ProjectPermissions.Read) - data = ProjectSchema().dump(project) + data = ProjectSchemaV2().dump(project) if files_at_version: pv = ProjectVersion.query.filter_by( project_id=project.id, name=ProjectVersion.from_v_name(files_at_version)