From 9dc818e92d01daa06365744aa6c7531246d2fc4e Mon Sep 17 00:00:00 2001 From: Martin Varga Date: Fri, 22 May 2026 12:40:15 +0200 Subject: [PATCH] Add a signal for soft-deleted projects Other components can listen on signal and add custom handling. Also soft-delete action was moved to single method instead of being scattered in code. --- server/mergin/sync/commands.py | 4 +--- server/mergin/sync/models.py | 11 +++++++++++ server/mergin/sync/public_api_controller.py | 4 +--- server/mergin/sync/public_api_v2_controller.py | 4 +--- server/mergin/tests/test_project_controller.py | 17 +++++++++++++++++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/server/mergin/sync/commands.py b/server/mergin/sync/commands.py index 882b73b6..4262e60f 100644 --- a/server/mergin/sync/commands.py +++ b/server/mergin/sync/commands.py @@ -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() diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index 5f4aa967..fbf59023 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -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") @@ -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) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 8f142e71..fd098815 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -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 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index ebd909ad..e7806865 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -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 diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 4cb3374a..c11821e6 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -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 @@ -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(