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
4 changes: 1 addition & 3 deletions server/mergin/sync/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ def remove(project_name):
if not project:
click.secho("ERROR: Project does not exist", fg="red", err=True)
sys.exit(1)
project.removed_at = datetime.utcnow()
project.removed_by = None
db.session.commit()
project.schedule_deletion()
click.secho("Project removed", fg="green")

@project.command()
Expand Down
11 changes: 11 additions & 0 deletions server/mergin/sync/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@

Storages = {"local": DiskStorage}
project_deleted = signal("project_deleted")
project_soft_deleted = signal("project_soft_deleted")
project_access_granted = signal("project_access_granted")
push_finished = signal("push_finished")
project_version_created = signal("project_version_created")
Expand Down Expand Up @@ -286,6 +287,16 @@ def expiration(self) -> timedelta:
initial = timedelta(days=current_app.config["DELETED_PROJECT_EXPIRATION"])
return initial - (datetime.utcnow() - self.removed_at)

def schedule_deletion(self, removed_by: int = None):
"""Schedule project for removal (soft-delete).
Sets removed_at so the project is hidden from users but kept in db
until a background job permanently deletes it.
"""
self.removed_at = datetime.utcnow()
self.removed_by = removed_by
db.session.commit()
project_soft_deleted.send(self)

def delete(self, removed_by: int = None):
"""Mark project as permanently deleted (but keep in db)
- rename (to free up the same name)
Expand Down
4 changes: 1 addition & 3 deletions server/mergin/sync/public_api_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,7 @@ def delete_project(namespace, project_name): # noqa: E501
:rtype: None
"""
project = require_project(namespace, project_name, ProjectPermissions.Delete)
project.removed_at = datetime.utcnow()
project.removed_by = current_user.id
db.session.commit()
project.schedule_deletion(removed_by=current_user.id)
return NoContent, 200


Expand Down
4 changes: 1 addition & 3 deletions server/mergin/sync/public_api_v2_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ def schedule_delete_project(id):
rest.
"""
project = require_project_by_uuid(id, ProjectPermissions.Delete)
project.removed_at = datetime.utcnow()
project.removed_by = current_user.id
db.session.commit()
project.schedule_deletion(removed_by=current_user.id)

return NoContent, 204

Expand Down
17 changes: 17 additions & 0 deletions server/mergin/tests/test_project_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
FileHistory,
PushChangeType,
ProjectFilePath,
project_soft_deleted,
)
from ..sync.storages.disk import copy_file as real_copy_file
from ..sync.files import files_changes_from_upload
Expand Down Expand Up @@ -2575,6 +2576,22 @@ def test_signals(client):
project_version_created_mock.assert_called_once()


def test_project_soft_delete(client):
"""project.schedule_deletion() sets removed_at/removed_by and fires project_soft_deleted signal"""
workspace = create_workspace()
user = User.query.filter_by(username="mergin").first()
project = create_project("remove-test", workspace, user)

with patch("mergin.sync.models.project_soft_deleted.send") as signal_mock:
project.schedule_deletion(removed_by=user.id)
signal_mock.assert_called_once_with(project)

assert project.removed_at is not None
assert project.removed_by == user.id
# project storage params is still present (soft delete)
assert project.storage_params is not None


def test_filepath_manipulation(client):
"""Test filepath validation during file upload"""
push_start_url = url_for(
Expand Down
Loading