diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index fd77c597..cdd0498a 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -230,5 +230,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 9ed062d5..bf3db007 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: @@ -502,6 +531,72 @@ 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: + 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 6717a083..27e0355a 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -14,6 +14,7 @@ from marshmallow import ValidationError from sqlalchemy.exc import IntegrityError +from .schemas_v2 import ProjectSchema as ProjectSchemaV2 from ..app import db from ..auth import auth_required from ..auth.models import User @@ -26,7 +27,7 @@ StorageLimitHit, UploadError, ) -from .files import ChangesSchema +from .files import ChangesSchema, ProjectFileSchema from .forms import project_name_validation from .models import ( Project, @@ -162,6 +163,22 @@ def remove_project_collaborator(id, user_id): 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() + if pv: + data["files"] = ProjectFileSchema( + only=("path", "mtime", "size", "checksum"), many=True + ).dump(pv.files) + + return data, 200 + + @auth_required @catch_sync_failure def create_project_version(id): diff --git a/server/mergin/sync/schemas_v2.py b/server/mergin/sync/schemas_v2.py new file mode 100644 index 00000000..d6b781ee --- /dev/null +++ b/server/mergin/sync/schemas_v2.py @@ -0,0 +1,48 @@ +# 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) + return role.value if role else None + + 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 85177190..dda0bc53 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1,6 +1,18 @@ # Copyright (C) Lutra Consulting Limited # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +from . import DEFAULT_USER +from .utils import ( + add_user, + logout, + login_as_admin, + create_workspace, + create_project, + upload_file_to_project, +) + +from ..auth.models import User import os import shutil from unittest.mock import patch @@ -156,6 +168,73 @@ def test_project_members(client): 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) + logout(client) + # 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 == 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}" + ) + 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) + + push_data = [ # success ( diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 6dcfd157..dab9c02c 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -379,3 +379,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