From 2bfb070d1ea5407ddfaadd5fda658a8e30c3f0c0 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Tue, 29 Sep 2026 16:46:54 +0200 Subject: [PATCH 1/2] Add v2 endpoint to create project in workspace --- server/mergin/sync/public_api_controller.py | 20 +---- server/mergin/sync/public_api_v2.yaml | 76 +++++++++++++++++++ .../mergin/sync/public_api_v2_controller.py | 55 ++++++++++++++ server/mergin/sync/utils.py | 11 +++ server/mergin/tests/test_public_api_v2.py | 60 +++++++++++++++ 5 files changed, 205 insertions(+), 17 deletions(-) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 43ecd1cb..a27422a8 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -85,11 +85,11 @@ is_valid_uuid, is_versioned_file, prepare_download_response, + project_name_conflict_message, wkb2wkt, ) from ..utils import get_ip, get_user_agent, get_device_id from .errors import StorageLimitHit, ProjectLocked -from ..utils import format_time_delta def parse_project_access_update_request(access: Dict) -> Dict: @@ -201,14 +201,7 @@ def add_project(namespace): # noqa: E501 name=request.json["name"], workspace_id=workspace.id ).first() if proj: - if proj.removed_at: - msg = ( - f"Project with the same name is scheduled for deletion, " - f"you can create a project with this name in {format_time_delta(proj.expiration)}" - ) - else: - msg = "Project with the same name already exists" - abort(409, msg) + abort(409, project_name_conflict_message(proj)) request.json["storage_params"] = { "type": "local", @@ -1283,14 +1276,7 @@ def clone_project(namespace, project_name): # noqa: E501 _project = Project.query.filter_by(name=dest_project, workspace_id=ws.id).first() if _project: - if _project.removed_at: - msg = ( - f"Project with the same name is scheduled for deletion, " - f"you can create a project with this name in {format_time_delta(_project.expiration)}" - ) - else: - msg = "Project with the same name already exists" - abort(409, msg) + abort(409, project_name_conflict_message(_project)) # Check storage limit additional_storage = cloned_project.disk_usage diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index b351654f..e63915d7 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -550,6 +550,71 @@ paths: "404": $ref: "#/components/responses/NotFound" x-openapi-router-controller: mergin.sync.public_api_v2_controller + post: + tags: + - workspace + summary: Create a new empty project in the workspace + operationId: create_project + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + example: survey + public: + type: boolean + default: false + example: false + responses: + "201": + description: New project + content: + application/json: + schema: + $ref: "#/components/schemas/Project" + "400": + description: Invalid project name + content: + application/json: + schema: + type: object + required: + - code + - detail + properties: + code: + type: string + enum: + - InvalidProjectName + example: InvalidProjectName + detail: + type: string + example: "Entered project name is invalid" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "422": + description: New project cannot be created in the workspace + content: + application/problem+json: + schema: + anyOf: + - $ref: "#/components/schemas/ProjectsLimitHit" + - $ref: "#/components/schemas/TrialExpired" + x-openapi-router-controller: mergin.sync.public_api_v2_controller components: responses: NoContent: @@ -617,6 +682,17 @@ components: detail: You have reached a data limit (StorageLimitHit) current_usage: 24865 storage_limit: 24865 + ProjectsLimitHit: + allOf: + - $ref: "#/components/schemas/CustomError" + type: object + properties: + projects_quota: + type: integer + example: + code: ProjectsLimitHit + detail: Maximum number of projects is reached. Please upgrade your subscription to create new projects (ProjectsLimitHit) + projects_quota: 2 ProjectLocked: allOf: - $ref: "#/components/schemas/CustomError" diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 6dc54dbd..d1a31ea1 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -60,9 +60,12 @@ ) from .schemas_v2 import ProjectSchema as ProjectSchemaV2 from .storages.disk import move_to_tmp, save_to_file +from .storages.storage import InitializationError from .utils import ( + generate_location, get_chunk_location, prepare_download_response, + project_name_conflict_message, ) from ..utils import get_ip, get_user_agent, get_device_id from .tasks import remove_transaction_chunks @@ -567,6 +570,58 @@ def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=N return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 +@auth_required +def create_project(workspace_id): + """Create a new empty project in the workspace""" + ws = current_app.ws_handler.get(workspace_id) + if not (ws and ws.is_active): + abort(404, "Workspace not found") + + if not ws.user_has_permissions(current_user, "admin"): + abort(403, "You do not have permissions for this workspace") + + name = request.json["name"].strip() + validation_error = project_name_validation(name) + if validation_error: + return ( + jsonify(code="InvalidProjectName", detail=validation_error), + 400, + ) + + existing_project = Project.query.filter_by(name=name, workspace_id=ws.id).first() + if existing_project: + abort(409, project_name_conflict_message(existing_project)) + + project = Project( + name=name, + storage_params={"type": "local", "location": generate_location()}, + creator=current_user, + workspace=ws, + public=request.json.get("public", False), + ) + project.updated = datetime.utcnow() + try: + project.storage.initialize() + except InitializationError as e: + abort(400, f"Failed to initialize project: {str(e)}") + + pv = ProjectVersion( + project, + 0, + current_user.id, + [], + get_ip(request), + get_user_agent(request), + get_device_id(request), + ) + db.session.add(project) + db.session.add(pv) + db.session.commit() + project_version_created.send(pv) + + return ProjectSchemaV2().dump(project), 201 + + def list_batch_projects(body): """List projects by given list of UUIDs. Limit to 100 projects per request. diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index e1c06678..d31d4d49 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -34,6 +34,7 @@ from pathlib import Path from .config import Configuration +from ..utils import format_time_delta # log base for caching strategy, diff checkpoints, etc. LOG_BASE = 4 @@ -113,6 +114,16 @@ def generate_location(): return os.path.join(secrets.token_hex(1), secrets.token_hex(16)) +def project_name_conflict_message(project) -> str: + """Return error message for a new project clashing with an existing project name""" + if project.removed_at: + return ( + f"Project with the same name is scheduled for deletion, " + f"you can create a project with this name in {format_time_delta(project.expiration)}" + ) + return "Project with the same name already exists" + + def is_valid_uuid(uuid): """Check object can be parse as valid UUID""" try: diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index d44516d1..94a220c5 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1489,6 +1489,66 @@ def test_list_workspace_projects(client): assert client.get(url + "?page=1&per_page=10").status_code == 401 +def test_create_project(client): + url = f"v2/workspaces/{test_workspace_id}/projects" + response = client.post(url, json={"name": " new_project "}) + assert response.status_code == 201 + assert response.json["name"] == "new_project" + assert response.json["version"] == "v0" + assert response.json["size"] == 0 + assert response.json["workspace"]["id"] == test_workspace_id + assert response.json["role"] == "owner" + assert response.json["public"] is False + assert "files" not in response.json + project = Project.query.filter_by( + workspace_id=test_workspace_id, name="new_project" + ).first() + assert str(project.id) == response.json["id"] + assert project.latest_version == 0 + assert os.path.exists(project.storage.project_dir) + + # name already exists + response = client.post(url, json={"name": "new_project"}) + assert response.status_code == 409 + assert response.json["detail"] == "Project with the same name already exists" + # name is taken by project scheduled for deletion + project.removed_at = datetime.utcnow() + db.session.commit() + response = client.post(url, json={"name": "new_project"}) + assert response.status_code == 409 + assert "scheduled for deletion" in response.json["detail"] + + # invalid project name + for name in ["", ".new_project"]: + response = client.post(url, json={"name": name}) + assert response.status_code == 400 + assert response.json["code"] == "InvalidProjectName" + assert client.post(url, json={}).status_code == 400 + + # public project + response = client.post(url, json={"name": "public_project", "public": True}) + assert response.status_code == 201 + assert response.json["public"] is True + assert Project.query.filter_by(name="public_project").first().public + + # not existing workspace + response = client.post("v2/workspaces/1234/projects", json={"name": "other"}) + assert response.status_code == 404 + + # workspace writer cannot create projects + user = add_user("user", "password") + login(client, user.username, "password") + with patch.object(Configuration, "GLOBAL_ADMIN", 0), patch.object( + Configuration, "GLOBAL_WRITE", 1 + ): + response = client.post(url, json={"name": "other"}) + assert response.status_code == 403 + + logout(client) + assert client.post(url, json={"name": "other"}).status_code == 401 + assert not Project.query.filter_by(name="other").count() + + def test_list_projects_in_batch(client): """Test batch project listing endpoint.""" admin = User.query.filter_by(username=DEFAULT_USER[0]).first() From a29609026dfb1c8cdf180a1e8a988d896d5b7fde Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Wed, 30 Sep 2026 09:41:07 +0200 Subject: [PATCH 2/2] Drop public flag from v2 project creation --- server/mergin/sync/public_api_v2.yaml | 4 ---- server/mergin/sync/public_api_v2_controller.py | 1 - server/mergin/tests/test_public_api_v2.py | 7 ------- 3 files changed, 12 deletions(-) diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index e63915d7..32b232e7 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -569,10 +569,6 @@ paths: name: type: string example: survey - public: - type: boolean - default: false - example: false responses: "201": description: New project diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index d1a31ea1..83cef245 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -597,7 +597,6 @@ def create_project(workspace_id): storage_params={"type": "local", "location": generate_location()}, creator=current_user, workspace=ws, - public=request.json.get("public", False), ) project.updated = datetime.utcnow() try: diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 94a220c5..7722f54a 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1498,7 +1498,6 @@ def test_create_project(client): assert response.json["size"] == 0 assert response.json["workspace"]["id"] == test_workspace_id assert response.json["role"] == "owner" - assert response.json["public"] is False assert "files" not in response.json project = Project.query.filter_by( workspace_id=test_workspace_id, name="new_project" @@ -1525,12 +1524,6 @@ def test_create_project(client): assert response.json["code"] == "InvalidProjectName" assert client.post(url, json={}).status_code == 400 - # public project - response = client.post(url, json={"name": "public_project", "public": True}) - assert response.status_code == 201 - assert response.json["public"] is True - assert Project.query.filter_by(name="public_project").first().public - # not existing workspace response = client.post("v2/workspaces/1234/projects", json={"name": "other"}) assert response.status_code == 404