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..32b232e7 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -550,6 +550,67 @@ 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 + 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 +678,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..83cef245 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,57 @@ 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, + ) + 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..7722f54a 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -1489,6 +1489,59 @@ 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 "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 + + # 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()