Skip to content

Commit 88885a3

Browse files
authored
Merge pull request #531 from MerginMaps/project_detail_v2
Project info v2
2 parents 14f51a2 + 072e4bb commit 88885a3

6 files changed

Lines changed: 247 additions & 2 deletions

File tree

server/mergin/sync/files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,5 +230,5 @@ class ProjectFileSchema(FileSchema):
230230
def patch_field(self, data, **kwargs):
231231
# drop 'diff' key entirely if empty or None as clients would expect
232232
if not data.get("diff"):
233-
data.pop("diff")
233+
data.pop("diff", None)
234234
return data

server/mergin/sync/public_api_v2.yaml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,35 @@ paths:
7676
"409":
7777
$ref: "#/components/responses/Conflict"
7878
x-openapi-router-controller: mergin.sync.public_api_v2_controller
79+
get:
80+
tags:
81+
- project
82+
summary: Get project info
83+
operationId: get_project
84+
parameters:
85+
- name: files_at_version
86+
in: query
87+
description: Include list of files at specific version
88+
required: false
89+
schema:
90+
type: string
91+
example: v3
92+
responses:
93+
"200":
94+
description: Success
95+
content:
96+
application/json:
97+
schema:
98+
$ref: "#/components/schemas/ProjectDetail"
99+
"400":
100+
$ref: "#/components/responses/BadRequest"
101+
"401":
102+
$ref: "#/components/responses/Unauthorized"
103+
"403":
104+
$ref: "#/components/responses/Forbidden"
105+
"404":
106+
$ref: "#/components/responses/NotFound"
107+
x-openapi-router-controller: mergin.sync.public_api_v2_controller
79108
/projects/{id}/scheduleDelete:
80109
post:
81110
tags:
@@ -502,6 +531,72 @@ components:
502531
$ref: "#/components/schemas/ProjectRole"
503532
role:
504533
$ref: "#/components/schemas/Role"
534+
ProjectDetail:
535+
type: object
536+
required:
537+
- id
538+
- name
539+
- workspace
540+
- role
541+
- version
542+
- created_at
543+
- updated_at
544+
- public
545+
- size
546+
properties:
547+
id:
548+
type: string
549+
description: project uuid
550+
example: c1ae6439-0056-42df-a06d-79cc430dd7df
551+
name:
552+
type: string
553+
example: survey
554+
workspace:
555+
type: object
556+
properties:
557+
id:
558+
type: integer
559+
example: 123
560+
name:
561+
type: string
562+
example: mergin
563+
role:
564+
$ref: "#/components/schemas/ProjectRole"
565+
version:
566+
type: string
567+
description: latest project version
568+
example: v2
569+
created_at:
570+
type: string
571+
format: date-time
572+
description: project creation timestamp
573+
example: 2025-10-24T08:27:56Z
574+
updated_at:
575+
type: string
576+
format: date-time
577+
description: last project update timestamp
578+
example: 2025-10-24T08:28:00.279699Z
579+
public:
580+
type: boolean
581+
description: whether the project is public
582+
example: false
583+
size:
584+
type: integer
585+
description: project size in bytes for this version
586+
example: 17092380
587+
files:
588+
type: array
589+
description: List of files in the project
590+
items:
591+
allOf:
592+
- $ref: '#/components/schemas/File'
593+
- type: object
594+
properties:
595+
mtime:
596+
type: string
597+
format: date-time
598+
description: File modification timestamp
599+
example: 2024-11-19T13:50:00Z
505600
File:
506601
type: object
507602
description: Project file metadata

server/mergin/sync/public_api_v2_controller.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from marshmallow import ValidationError
1515
from sqlalchemy.exc import IntegrityError
1616

17+
from .schemas_v2 import ProjectSchema as ProjectSchemaV2
1718
from ..app import db
1819
from ..auth import auth_required
1920
from ..auth.models import User
@@ -26,7 +27,7 @@
2627
StorageLimitHit,
2728
UploadError,
2829
)
29-
from .files import ChangesSchema
30+
from .files import ChangesSchema, ProjectFileSchema
3031
from .forms import project_name_validation
3132
from .models import (
3233
Project,
@@ -162,6 +163,22 @@ def remove_project_collaborator(id, user_id):
162163
return NoContent, 204
163164

164165

166+
def get_project(id, files_at_version=None):
167+
"""Get project info. Include list of files at specific version if requested."""
168+
project = require_project_by_uuid(id, ProjectPermissions.Read)
169+
data = ProjectSchemaV2().dump(project)
170+
if files_at_version:
171+
pv = ProjectVersion.query.filter_by(
172+
project_id=project.id, name=ProjectVersion.from_v_name(files_at_version)
173+
).first()
174+
if pv:
175+
data["files"] = ProjectFileSchema(
176+
only=("path", "mtime", "size", "checksum"), many=True
177+
).dump(pv.files)
178+
179+
return data, 200
180+
181+
165182
@auth_required
166183
@catch_sync_failure
167184
def create_project_version(id):

server/mergin/sync/schemas_v2.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright (C) Lutra Consulting Limited
2+
#
3+
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
from marshmallow import fields
6+
from flask_login import current_user
7+
8+
from ..app import DateTimeWithZ, ma
9+
from .permissions import ProjectPermissions
10+
from .models import (
11+
Project,
12+
ProjectVersion,
13+
)
14+
15+
16+
class ProjectSchema(ma.SQLAlchemyAutoSchema):
17+
id = fields.UUID()
18+
name = fields.String()
19+
version = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.latest_version))
20+
public = fields.Boolean()
21+
size = fields.Integer(attribute="disk_usage")
22+
23+
created_at = DateTimeWithZ(attribute="created")
24+
updated_at = DateTimeWithZ(attribute="updated")
25+
26+
workspace = fields.Function(
27+
lambda obj: {"id": obj.workspace.id, "name": obj.workspace.name}
28+
)
29+
role = fields.Method("_role")
30+
31+
def _role(self, obj):
32+
role = ProjectPermissions.get_user_project_role(obj, current_user)
33+
return role.value if role else None
34+
35+
class Meta:
36+
model = Project
37+
load_instance = True
38+
fields = (
39+
"id",
40+
"name",
41+
"version",
42+
"public",
43+
"size",
44+
"created_at",
45+
"updated_at",
46+
"workspace",
47+
"role",
48+
)

server/mergin/tests/test_public_api_v2.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# Copyright (C) Lutra Consulting Limited
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
4+
5+
from . import DEFAULT_USER
6+
from .utils import (
7+
add_user,
8+
logout,
9+
login_as_admin,
10+
create_workspace,
11+
create_project,
12+
upload_file_to_project,
13+
)
14+
15+
from ..auth.models import User
416
import os
517
import shutil
618
from unittest.mock import patch
@@ -156,6 +168,73 @@ def test_project_members(client):
156168
assert response.status_code == 404
157169

158170

171+
def test_get_project(client):
172+
"""Test get project info endpoint"""
173+
admin = User.query.filter_by(username=DEFAULT_USER[0]).first()
174+
test_workspace = create_workspace()
175+
project = create_project("new_project", test_workspace, admin)
176+
logout(client)
177+
# lack of permissions
178+
response = client.get(f"v2/projects/{project.id}")
179+
assert response.status_code == 403
180+
# access public project
181+
project.public = True
182+
db.session.commit()
183+
response = client.get(f"v2/projects/{project.id}")
184+
assert response.status_code == 200
185+
assert response.json["public"] is True
186+
# project scheduled for deletion
187+
login_as_admin(client)
188+
project.public = False
189+
project.removed_at = datetime.utcnow()
190+
db.session.commit()
191+
response = client.get(f"v2/projects/{project.id}")
192+
assert response.status_code == 404
193+
# success
194+
project.removed_at = None
195+
db.session.commit()
196+
response = client.get(f"v2/projects/{project.id}")
197+
assert response.status_code == 200
198+
expected_keys = {
199+
"id",
200+
"name",
201+
"workspace",
202+
"role",
203+
"version",
204+
"created_at",
205+
"updated_at",
206+
"public",
207+
"size",
208+
}
209+
assert expected_keys == response.json.keys()
210+
# create new versions
211+
files = ["test.txt", "test3.txt", "test.qgs"]
212+
for file in files:
213+
upload_file_to_project(project, file, client)
214+
# project version does not exist
215+
response = client.get(
216+
f"v2/projects/{project.id}?files_at_version=v{project.latest_version+1}"
217+
)
218+
assert response.status_code == 200
219+
assert response.json["id"] == str(project.id)
220+
assert "files" not in response.json.keys()
221+
# files
222+
response = client.get(
223+
f"v2/projects/{project.id}?files_at_version=v{project.latest_version-2}"
224+
)
225+
assert response.status_code == 200
226+
assert len(response.json["files"]) == 1
227+
assert any(resp_files["path"] == files[0] for resp_files in response.json["files"])
228+
assert not any(
229+
resp_files["path"] == files[1] for resp_files in response.json["files"]
230+
)
231+
response = client.get(
232+
f"v2/projects/{project.id}?files_at_version=v{project.latest_version}"
233+
)
234+
assert len(response.json["files"]) == 3
235+
assert {f["path"] for f in response.json["files"]} == set(files)
236+
237+
159238
push_data = [
160239
# success
161240
(

server/mergin/tests/utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,9 @@ def modify_file_times(path, time: datetime, accessed=True, modified=True):
379379
mtime = epoch_time if modified else file_stat.st_mtime
380380

381381
os.utime(path, (atime, mtime))
382+
383+
384+
def logout(client):
385+
"""Test helper to log out the client"""
386+
resp = client.get(url_for("/.mergin_auth_controller_logout"))
387+
assert resp.status_code == 200

0 commit comments

Comments
 (0)