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
20 changes: 3 additions & 17 deletions server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions server/mergin/sync/public_api_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
54 changes: 54 additions & 0 deletions server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions server/mergin/sync/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions server/mergin/tests/test_public_api_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading