Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/mergin/sync/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 95 additions & 0 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
48 changes: 48 additions & 0 deletions server/mergin/sync/schemas_v2.py
Original file line number Diff line number Diff line change
@@ -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",
)
79 changes: 79 additions & 0 deletions server/mergin/tests/test_public_api_v2.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
(
Expand Down
6 changes: 6 additions & 0 deletions server/mergin/tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading