From bfd28c35b69d32af50665c138cd33bad584858b5 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 10 Apr 2025 11:21:48 +0200 Subject: [PATCH 1/3] Add possibility to lock a project - add column to project table - block push - do not count to workspace storage and project number --- server/mergin/sync/models.py | 3 +- server/mergin/sync/private_api_controller.py | 4 +-- server/mergin/sync/public_api_controller.py | 10 +++++++ server/mergin/sync/workspace.py | 11 ++++--- .../mergin/tests/test_project_controller.py | 30 +++++++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index ba13b947..d557188c 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -16,7 +16,6 @@ from pygeodiff import GeoDiff from sqlalchemy import text, null, desc, nullslast from sqlalchemy.dialects.postgresql import ARRAY, BIGINT, UUID, JSONB, ENUM -from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.types import String from sqlalchemy.ext.hybrid import hybrid_property from pygeodiff.geodifflib import GeoDiffLibError @@ -68,6 +67,7 @@ class Project(db.Model): db.Integer, db.ForeignKey("user.id"), nullable=True, index=True ) public = db.Column(db.Boolean, default=False, index=True, nullable=False) + locked_until = db.Column(db.DateTime, index=True) creator = db.relationship( "User", uselist=False, backref=db.backref("projects"), foreign_keys=[creator_id] @@ -88,6 +88,7 @@ def __init__( self.creator = creator self.latest_version = 0 self.public = kwargs.get("public", False) + self.locked_until = None latest_files = LatestProjectFiles(project=self) db.session.add(latest_files) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 643c274c..073f8275 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -6,11 +6,10 @@ from flask import render_template, request, current_app, jsonify, abort from flask_login import current_user from sqlalchemy.orm import defer -from sqlalchemy import text, and_, desc, asc +from sqlalchemy import text from ..app import db from ..auth import auth_required -from ..auth.models import User, UserProfile from .forms import AccessPermissionForm from .models import ( Project, @@ -22,7 +21,6 @@ ProjectListSchema, ProjectAccessRequestSchema, AdminProjectSchema, - ProjectAccessSchema, ProjectAccessDetailSchema, ) from .permissions import ( diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 2a4829b9..b6857305 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -785,6 +785,11 @@ def project_push(namespace, project_name): changes = request.json["changes"] project_permission = current_app.project_handler.get_push_permission(changes) project = require_project(namespace, project_name, project_permission) + if project.locked_until: + abort( + 423, + f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + ) # pass full project object to request for later use request.view_args["project"] = project ws = project.workspace @@ -1027,6 +1032,11 @@ def push_finish(transaction_id): upload.changes ) project = upload.project + if project.locked_until: + abort( + 423, + f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + ) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 79aef2b9..2994d104 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from typing import Dict, Tuple, Optional, Set, List from flask_login import current_user -from sqlalchemy import Column, literal, extract +from sqlalchemy import Column, literal, extract, and_ from sqlalchemy.sql.operators import is_ from .errors import UpdateProjectAccessError @@ -66,7 +66,7 @@ def disk_usage(self): projects_usage_list = ( db.session.query(Project.disk_usage) - .filter(Project.removed_at.is_(None)) + .filter(Project.removed_at.is_(None), Project.locked_until.is_(None)) .all() ) return sum(p.disk_usage for p in projects_usage_list) @@ -107,8 +107,11 @@ def project_count(self): return ( db.session.query(Project.disk_usage) - .filter(Project.workspace_id == self.id) - .filter(Project.removed_at.is_(None)) + .filter( + Project.workspace_id == self.id, + Project.removed_at.is_(None), + Project.locked_until.is_(None), + ) .count() ) diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 652932f5..5f8df253 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2621,3 +2621,33 @@ def test_supported_file_upload(client): resp.json["detail"] == f"Unsupported file type detected: {spoof_name}. Please remove the file or try compressing it into a ZIP file before uploading." ) + + +def test_locked_project(client, diff_project): + """Users cannot push to the locked project. Moreover, it does not count to the storage and project count67""" + # before project is locked + orig_p_count = diff_project.workspace.project_count() + orig_storage = diff_project.workspace.disk_usage() + # after locking the project + diff_project.locked_until = datetime.datetime.utcnow() + datetime.timedelta( + weeks=26 + ) + db.session.commit() + assert diff_project.workspace.project_count() == orig_p_count - 1 + assert diff_project.workspace.disk_usage() == orig_storage - diff_project.disk_usage + # push is not possible to the locked project + changes = _get_changes_without_added(test_project_dir) + project_path = get_project_path(diff_project) + data = {"version": "v1", "changes": changes} + resp = client.post( + f"/v1/project/push/{project_path}", + data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), + headers=json_headers, + ) + assert resp.status_code == 423 + # to play safe push finish is also blocked + upload, upload_dir = create_transaction("mergin", changes) + url = "/v1/project/push/finish/{}".format(upload.id) + + resp = client.post(url, headers=json_headers) + assert resp.status_code == 423 From d9197992ca0e8537886496989e6ee5df71f5ce1d Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 11 Apr 2025 13:05:31 +0200 Subject: [PATCH 2/3] Adjust error type and message --- server/mergin/sync/models.py | 1 - server/mergin/sync/public_api_controller.py | 8 +++--- .../mergin/tests/test_project_controller.py | 10 +++++-- .../6cb54659c1de_add_locked_until.py | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 server/migrations/community/6cb54659c1de_add_locked_until.py diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index d557188c..b7f9dc64 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -88,7 +88,6 @@ def __init__( self.creator = creator self.latest_version = 0 self.public = kwargs.get("public", False) - self.locked_until = None latest_files = LatestProjectFiles(project=self) db.session.add(latest_files) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index b6857305..f2f45e33 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -787,8 +787,8 @@ def project_push(namespace, project_name): project = require_project(namespace, project_name, project_permission) if project.locked_until: abort( - 423, - f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + 400, + f"This project is currently locked and you cannot make changes to it.", ) # pass full project object to request for later use request.view_args["project"] = project @@ -1034,8 +1034,8 @@ def push_finish(transaction_id): project = upload.project if project.locked_until: abort( - 423, - f"This project is currently locked. You cannot make changes to it until {project.locked_until.strftime('%Y-%m-%d %H:%M UTC')}.", + 400, + f"This project is currently locked and you cannot make changes to it.", ) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 5f8df253..68261553 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2624,7 +2624,7 @@ def test_supported_file_upload(client): def test_locked_project(client, diff_project): - """Users cannot push to the locked project. Moreover, it does not count to the storage and project count67""" + """Users cannot push to the locked project. Moreover, it does not count to the storage and project count""" # before project is locked orig_p_count = diff_project.workspace.project_count() orig_storage = diff_project.workspace.disk_usage() @@ -2644,10 +2644,14 @@ def test_locked_project(client, diff_project): data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), headers=json_headers, ) - assert resp.status_code == 423 + assert resp.status_code == 400 + assert ( + resp.json.get("detail") + == "This project is currently locked and you cannot make changes to it." + ) # to play safe push finish is also blocked upload, upload_dir = create_transaction("mergin", changes) url = "/v1/project/push/finish/{}".format(upload.id) resp = client.post(url, headers=json_headers) - assert resp.status_code == 423 + assert resp.status_code == 400 diff --git a/server/migrations/community/6cb54659c1de_add_locked_until.py b/server/migrations/community/6cb54659c1de_add_locked_until.py new file mode 100644 index 00000000..ff82a22d --- /dev/null +++ b/server/migrations/community/6cb54659c1de_add_locked_until.py @@ -0,0 +1,28 @@ +"""Add locked_until to project + +Revision ID: 6cb54659c1de +Revises: 5ad13be6f7ef +Create Date: 2025-04-10 11:11:09.277522 + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "6cb54659c1de" +down_revision = "5ad13be6f7ef" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("project", sa.Column("locked_until", sa.DateTime(), nullable=True)) + op.create_index( + op.f("ix_project_locked_until"), "project", ["locked_until"], unique=False + ) + + +def downgrade(): + op.drop_index(op.f("ix_project_locked_until"), table_name="project") + op.drop_column("project", "locked_until") From 6e07388a44cc63fe249f8f63b381aa484b856b5f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 14 Apr 2025 11:17:06 +0200 Subject: [PATCH 3/3] Add custom error for blocking push to locked project --- server/mergin/sync/errors.py | 5 +++++ server/mergin/sync/public_api.yaml | 13 +++++++++++++ server/mergin/sync/public_api_controller.py | 12 +++--------- server/mergin/tests/test_project_controller.py | 12 ++++++------ 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/server/mergin/sync/errors.py b/server/mergin/sync/errors.py index fa22d858..d253ef4c 100644 --- a/server/mergin/sync/errors.py +++ b/server/mergin/sync/errors.py @@ -34,3 +34,8 @@ def to_dict(self) -> Dict: data["current_usage"] = self.current_usage data["storage_limit"] = self.storage_limit return data + + +class ProjectLocked(ResponseError): + code = "ProjectLocked" + detail = "The project is currently locked and you cannot make changes to it" diff --git a/server/mergin/sync/public_api.yaml b/server/mergin/sync/public_api.yaml index c8072ad6..2eeb8a46 100644 --- a/server/mergin/sync/public_api.yaml +++ b/server/mergin/sync/public_api.yaml @@ -652,6 +652,7 @@ paths: - $ref: "#/components/schemas/UnprocessableEntityError" - $ref: "#/components/schemas/TrialExpired" - $ref: "#/components/schemas/StorageLimitHit" + - $ref: "#/components/schemas/ProjectLocked" x-openapi-router-controller: mergin.sync.public_api_controller #not implemented in connexion, going directly to flask endpoint # /project/push/chunk/{transaction_id}/{chunk_id}: @@ -760,6 +761,12 @@ paths: $ref: "#/components/responses/NotFoundResp" "409": $ref: "#/components/responses/ConflictResp" + "422": + description: Push is not allowed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProjectLocked' x-openapi-router-controller: mergin.sync.public_api_controller /project/push/cancel/{transaction_id}: post: @@ -1493,3 +1500,9 @@ components: size: type: integer example: 512 + ProjectLocked: + allOf: + - $ref: '#/components/schemas/CustomError' + example: + code: ProjectLocked + detail: The project is currently locked and you cannot make changes to it diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index f2f45e33..2763ca54 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -92,7 +92,7 @@ is_supported_extension, get_mimetype, ) -from .errors import StorageLimitHit +from .errors import StorageLimitHit, ProjectLocked from ..utils import format_time_delta push_finished = signal("push_finished") @@ -786,10 +786,7 @@ def project_push(namespace, project_name): project_permission = current_app.project_handler.get_push_permission(changes) project = require_project(namespace, project_name, project_permission) if project.locked_until: - abort( - 400, - f"This project is currently locked and you cannot make changes to it.", - ) + abort(make_response(jsonify(ProjectLocked().to_dict()), 422)) # pass full project object to request for later use request.view_args["project"] = project ws = project.workspace @@ -1033,10 +1030,7 @@ def push_finish(transaction_id): ) project = upload.project if project.locked_until: - abort( - 400, - f"This project is currently locked and you cannot make changes to it.", - ) + abort(make_response(jsonify(ProjectLocked().to_dict()), 422)) project_path = get_project_path(project) corrupted_files = [] diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 68261553..975377ed 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -2644,14 +2644,14 @@ def test_locked_project(client, diff_project): data=json.dumps(data, cls=DateTimeEncoder).encode("utf-8"), headers=json_headers, ) - assert resp.status_code == 400 - assert ( - resp.json.get("detail") - == "This project is currently locked and you cannot make changes to it." - ) + assert resp.status_code == 422 + assert resp.headers["Content-Type"] == "application/problem+json" + assert resp.json["code"] == "ProjectLocked" # to play safe push finish is also blocked upload, upload_dir = create_transaction("mergin", changes) url = "/v1/project/push/finish/{}".format(upload.id) resp = client.post(url, headers=json_headers) - assert resp.status_code == 400 + assert resp.status_code == 422 + assert resp.headers["Content-Type"] == "application/problem+json" + assert resp.json["code"] == "ProjectLocked"