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
5 changes: 5 additions & 0 deletions server/mergin/sync/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion server/mergin/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
4 changes: 1 addition & 3 deletions server/mergin/sync/private_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,7 +21,6 @@
ProjectListSchema,
ProjectAccessRequestSchema,
AdminProjectSchema,
ProjectAccessSchema,
ProjectAccessDetailSchema,
)
from .permissions import (
Expand Down
13 changes: 13 additions & 0 deletions server/mergin/sync/public_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
6 changes: 5 additions & 1 deletion server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -785,6 +785,8 @@ 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(make_response(jsonify(ProjectLocked().to_dict()), 422))
# pass full project object to request for later use
request.view_args["project"] = project
ws = project.workspace
Expand Down Expand Up @@ -1027,6 +1029,8 @@ def push_finish(transaction_id):
upload.changes
)
project = upload.project
if project.locked_until:
abort(make_response(jsonify(ProjectLocked().to_dict()), 422))
project_path = get_project_path(project)
corrupted_files = []

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

Expand Down
34 changes: 34 additions & 0 deletions server/mergin/tests/test_project_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2621,3 +2621,37 @@ 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 count"""
# 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 == 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 == 422
assert resp.headers["Content-Type"] == "application/problem+json"
assert resp.json["code"] == "ProjectLocked"
28 changes: 28 additions & 0 deletions server/migrations/community/6cb54659c1de_add_locked_until.py
Original file line number Diff line number Diff line change
@@ -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")
Loading