# Copyright 2021-2023 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the management command delete_expired_artifacts."""

import io
import threading
from collections.abc import Iterable
from datetime import timedelta
from typing import Any
from unittest import mock

from django.db import OperationalError, connection, transaction
from django.db.models.manager import Manager
from django.test import TestCase, TransactionTestCase
from django.utils import timezone

from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    DEFAULT_FILE_STORE_NAME,
    DEFAULT_WORKSPACE_NAME,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    Workspace,
    default_file_store,
)
from debusine.db.tests.utils import (
    RunInParallelTransaction,
    RunInThreadAndCloseDBConnections,
)
from debusine.server.management.commands.delete_expired_artifacts import (
    DeleteExpiredArtifacts,
)
from debusine.server.tests.commands import call_command
from debusine.test import TestHelpersMixin
from debusine.test.utils import tomorrow


class DeleteExpiredArtifactsCommandTests(TestHelpersMixin, TestCase):
    """Tests for delete_expired_artifacts command."""

    def setUp(self):
        """Set up common objects for the tests."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2, _ = self.create_artifact(expiration_delay=1)
        artifact_2.created_at = timezone.now() - timedelta(days=2)
        artifact_2.save()

        ArtifactRelation.objects.create(
            artifact=artifact_1,
            target=artifact_2,
            type=ArtifactRelation.Relations.BUILT_USING,
        )

    def test_delete_expired_artifacts(self):
        """Test delete_expired_artifacts command delete artifacts."""
        self.assertEqual(Artifact.objects.count(), 2)
        self.assertEqual(ArtifactRelation.objects.count(), 1)

        stdout, stderr, retval = call_command("delete_expired_artifacts")

        self.assertEqual(Artifact.objects.count(), 0)
        self.assertEqual(ArtifactRelation.objects.count(), 0)

        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

    def test_delete_expired_artifacts_dry_run_verbose(self):
        """Test delete_expired_artifacts artifacts dry-run: no deletion."""
        artifact_id_1, artifact_id_2 = Artifact.objects.all()

        # Add an artifact with a file
        artifact_id_3, _ = self.create_artifact(
            paths=["README"], create_files=True, expiration_delay=1
        )
        artifact_id_3.created_at = timezone.now() - timedelta(days=2)
        artifact_id_3.save()

        self.assertEqual(Artifact.objects.count(), 3)
        self.assertEqual(ArtifactRelation.objects.count(), 1)
        self.assertEqual(FileInStore.objects.all().count(), 1)

        stdout, stderr, retval = call_command(
            "delete_expired_artifacts", "--dry-run", verbosity=2
        )

        self.assertEqual(
            stdout,
            "dry-run mode: no changes will be made\n"
            + _format_deleted_artifacts(
                {artifact_id_1, artifact_id_2, artifact_id_3}
            )
            + "dry-run mode: no changes were made\n",
        )

        # Artifacts, ArtifactRelation didn't get deleted
        self.assertEqual(Artifact.objects.count(), 3)
        self.assertEqual(ArtifactRelation.objects.count(), 1)

        # Files didn't get deleted
        self.assertEqual(FileInStore.objects.all().count(), 1)

    def test_delete_expired_artifacts_dry_run(self):
        """Test --dry-run without verbose does not write to stdout."""
        stdout, stderr, retval = call_command(
            "delete_expired_artifacts", "--dry-run"
        )

        # dry-run without verbose: no messages are written to stdout.
        self.assertEqual(stdout, "")

    def test_delete_expired_artifacts_verbose(self):
        """Test delete_expired_artifacts verbose: print information."""
        artifact_id_1 = Artifact.objects.all()[0]
        artifact_id_2 = Artifact.objects.all()[1]

        stdout, stderr, retval = call_command(
            "delete_expired_artifacts", verbosity=2
        )

        self.assertEqual(
            stdout, _format_deleted_artifacts({artifact_id_1, artifact_id_2})
        )
        self.assertEqual(Artifact.objects.count(), 0)
        self.assertEqual(ArtifactRelation.objects.count(), 0)

    def test_delete_expired_artifacts_verbose_nothing_deleted(self):
        """Test delete_expired_artifacts verbose: print no artifact expired."""
        Artifact.objects.update(created_at=tomorrow())
        stdout, stderr, retval = call_command(
            "delete_expired_artifacts", verbosity=2
        )

        self.assertEqual(stdout, "There were no expired artifacts\n")


class DeleteExpiredArtifactsTests(TestHelpersMixin, TestCase):
    """Tests for the DeleteExpiredArtifactsTests class."""

    def setUp(self):
        """Initialize test."""
        self.out = io.StringIO()
        self.err = io.StringIO()
        self.delete_expired_artifacts = DeleteExpiredArtifacts(
            out=self.out, err=self.err
        )
        self.delete_expired_artifacts._initial_time = timezone.now()

    def test_mark_to_keep_artifact_no_expiration_date(self):
        """One artifact, not expired: keep it."""
        artifact, _ = self.create_artifact(expiration_delay=0)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(), {artifact.id}
        )

    def test_mark_to_keep_artifact_expires_tomorrow(self):
        """One artifact, not expired (expires tomorrow): keep it."""
        artifact, _ = self.create_artifact(expiration_delay=1)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(), {artifact.id}
        )

    def test_mark_to_keep_only_artifact_is_expired(self):
        """One artifact, expired yesterday. No artifacts are kept."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()

        self.assertEqual(self.delete_expired_artifacts._mark_to_keep(), set())

    def test_mark_to_keep_all_artifacts_non_expired_or_targeted(self):
        """Keep both artifacts: the expired is targeted by the not-expired."""
        artifact_expired, _ = self.create_artifact(expiration_delay=1)
        artifact_expired.created_at = timezone.now() - timedelta(days=2)
        artifact_expired.save()
        artifact_not_expired, _ = self.create_artifact(expiration_delay=1)

        self.create_artifact_relation(artifact_not_expired, artifact_expired)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(),
            {artifact_expired.id, artifact_not_expired.id},
        )

    def test_mark_to_keep_no_artifact_all_expired_two(self):
        """Two artifacts (related), both expired."""
        artifact_expired_1, _ = self.create_artifact(expiration_delay=1)
        artifact_expired_1.created_at = timezone.now() - timedelta(days=2)
        artifact_expired_1.save()
        artifact_expired_2, _ = self.create_artifact(expiration_delay=1)
        artifact_expired_2.created_at = timezone.now() - timedelta(days=2)
        artifact_expired_2.save()

        self.create_artifact_relation(artifact_expired_1, artifact_expired_2)

        self.assertEqual(self.delete_expired_artifacts._mark_to_keep(), set())

    def test_mark_to_keep_no_artifact_all_expired_three(self):
        """All artifacts are expired."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2, _ = self.create_artifact(expiration_delay=1)
        artifact_2.created_at = timezone.now() - timedelta(days=2)
        artifact_2.save()
        artifact_3, _ = self.create_artifact(expiration_delay=1)
        artifact_3.created_at = timezone.now() - timedelta(days=2)
        artifact_3.save()

        self.create_artifact_relation(artifact_2, artifact_1)
        self.create_artifact_relation(artifact_3, artifact_2)

        self.assertEqual(self.delete_expired_artifacts._mark_to_keep(), set())

    def test_mark_to_keep_two_artifacts_non_expired_and_targeted(self):
        """Three artifacts, keep two (non-expired and target of non-expired)."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2, _ = self.create_artifact(expiration_delay=0)
        artifact_3, _ = self.create_artifact(expiration_delay=1)
        artifact_3.created_at = timezone.now() - timedelta(days=2)
        artifact_3.save()

        self.create_artifact_relation(artifact_2, artifact_1)
        self.create_artifact_relation(artifact_3, artifact_2)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(),
            {artifact_1.id, artifact_2.id},
        )

    def test_mark_to_keep_isolated(self):
        """Two expired and one non-expired isolated."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2, _ = self.create_artifact(expiration_delay=1)
        artifact_2.created_at = timezone.now() - timedelta(days=2)
        artifact_2.save()
        artifact_3, _ = self.create_artifact(expiration_delay=0)

        self.create_artifact_relation(artifact_2, artifact_1)
        self.create_artifact_relation(artifact_1, artifact_2)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(), {artifact_3.id}
        )

    def test_mark_to_keep_artifacts_circular_dependency(self):
        """Artifacts are not expired (keep), have a circular dependency."""
        artifact_1, _ = self.create_artifact(expiration_delay=0)
        artifact_2, _ = self.create_artifact(expiration_delay=0)

        self.create_artifact_relation(artifact_2, artifact_1)
        self.create_artifact_relation(artifact_1, artifact_2)

        self.assertEqual(
            self.delete_expired_artifacts._mark_to_keep(),
            {artifact_1.id, artifact_2.id},
        )

    def test_sweep_delete_artifacts(self):
        """Sweep() delete the artifacts and print progress."""
        artifact_1, _ = self.create_artifact(expiration_delay=1)
        artifact_1.created_at = timezone.now() - timedelta(days=2)
        artifact_1.save()
        artifact_2, _ = self.create_artifact(expiration_delay=1)
        artifact_2.created_at = timezone.now() - timedelta(days=2)
        artifact_2.save()

        self.create_artifact_relation(artifact_2, artifact_1)
        self.create_artifact_relation(artifact_1, artifact_2)

        artifact_3, _ = self.create_artifact(expiration_delay=1)
        artifact_3.created_at = timezone.now() - timedelta(days=2)
        artifact_3.save()
        artifact_4, _ = self.create_artifact(expiration_delay=0)

        self.create_artifact_relation(artifact_4, artifact_3)

        artifacts_to_keep = self.delete_expired_artifacts._mark_to_keep()

        self.delete_expired_artifacts._verbosity = 2

        self.delete_expired_artifacts._dry_run = False
        self.delete_expired_artifacts._sweep(artifacts_to_keep)

        self.assertEqual(Artifact.objects.all().count(), 2)
        self.assertEqual(
            self.out.getvalue(),
            _format_deleted_artifacts({artifact_1, artifact_2}),
        )

    def test_delete_artifact_all_related_files(self):
        """_delete_artifact() deletes all the artifact's related models."""
        path_in_artifact = "README"
        artifact, _ = self.create_artifact(
            paths=[path_in_artifact], create_files=True
        )

        # The file was added in the artifact and in the store
        self.assertEqual(FileInArtifact.objects.all().count(), 1)
        self.assertEqual(FileInStore.objects.all().count(), 1)
        self.assertEqual(File.objects.all().count(), 1)

        # Retrieve the store_backend for the file
        fileobj = File.objects.all().first()
        file_in_store = FileInStore.objects.all().first()
        store_backend = file_in_store.store.get_backend_object()

        # The file exists
        filepath = store_backend.get_local_path(fileobj)
        self.assertTrue(filepath.exists())

        # The Artifact is going to be deleted and the only file with it
        self.delete_expired_artifacts._dry_run = False
        self.delete_expired_artifacts._delete_artifact(artifact)

        self.assertFalse(Artifact.objects.filter(id=artifact.id).exists())
        self.assertEqual(FileInArtifact.objects.all().count(), 0)

        # FileInStore and File are not deleted: they are deleted after
        # the artifact is deleted in a different transaction (done in
        # DeleteExpiredArtifacts.run() )
        self.assertEqual(FileInStore.objects.all().count(), 1)
        self.assertEqual(File.objects.all().count(), 1)

        self.assertTrue(filepath.exists())

    def test_delete_artifact_not_related_files(self):
        """
        Two files created, _delete_artifact() delete the Artifact.

        Check that only the files that are supposed to be deleted are deleted.
        """
        file_to_keep_name = "README"  # used in two artifacts
        file_to_delete_name = (
            "another-file"  # used only in the deleted artifact
        )

        # Create the artifacts
        artifact_to_delete, _ = self.create_artifact(
            paths=[file_to_keep_name, file_to_delete_name], create_files=True
        )
        artifact_to_keep, _ = self.create_artifact()

        # Get both files
        file_to_keep = FileInArtifact.objects.get(
            artifact=artifact_to_delete, path=file_to_keep_name
        ).file
        file_to_delete = FileInArtifact.objects.get(
            artifact=artifact_to_delete, path=file_to_delete_name
        ).file

        # Add file_to_keep in the artifact that is not being deleted
        # (so the file is kept)
        FileInArtifact.objects.create(
            artifact=artifact_to_keep, file=file_to_keep
        )

        store_backend = FileInStore.objects.get(
            file=file_to_keep
        ).store.get_backend_object()

        file_to_keep_path = store_backend.get_local_path(file_to_keep)
        file_to_delete_path = store_backend.get_local_path(file_to_delete)

        # Both files exist on disk
        self.assertTrue(file_to_keep_path.exists())
        self.assertTrue(file_to_delete_path.exists())

        self.delete_expired_artifacts._dry_run = False

        files_to_delete = self.delete_expired_artifacts._delete_artifact(
            artifact_to_delete
        )

        self.assertCountEqual(files_to_delete, [file_to_keep, file_to_delete])

        # Both files still exist on disk (they would be deleted by
        # DeleteExpiredArtifacts._delete_files_from_stores)
        self.assertTrue(file_to_keep_path.exists())
        self.assertTrue(file_to_delete_path.exists())

    def test_delete_artifact_dry_run(self):
        """_delete_artifact() does nothing: running in dry-run."""
        artifact, _ = self.create_artifact(expiration_delay=1)
        artifact.created_at = timezone.now() - timedelta(days=2)
        artifact.save()

        # By default DeleteExpiredArtifacts runs in dry-run
        self.assertTrue(self.delete_expired_artifacts._dry_run)

        callables = self.delete_expired_artifacts._delete_artifact(artifact)

        # Artifact was not deleted
        self.assertTrue(Artifact.objects.filter(id=artifact.id).exists())

        # No callables to delete files after the transaction returned
        self.assertEqual(len(callables), 0)

    def test_delete_files_from_stores(self):
        """_delete_files_from_stores delete the file."""
        fileobj, _ = File.get_or_create(
            hash_digest=b"19acff6019bb9ce73083b9df3b7a838cf47df117", size=1024
        )

        FileInStore.objects.create(store=default_file_store(), file=fileobj)

        self.delete_expired_artifacts._delete_files_from_stores({fileobj})

        self.assertFalse(File.objects.filter(id=fileobj.id).exists())

    def test_delete_files_from_multiple_stores(self):
        """_delete_files_from_stores delete files from more than one store."""
        fileobj, _ = File.get_or_create(
            hash_digest=b"19acff6019bb9ce73083b9df3b7a838cf47df117", size=1024
        )

        # Add the file in the default_file_store()
        FileInStore.objects.create(store=default_file_store(), file=fileobj)

        # Add the same file in a secondary store
        secondary_store = FileStore.objects.create(
            name="Test",
            backend=FileStore.BackendChoices.LOCAL,
            configuration={"base_directory": "/"},
        )
        FileInStore.objects.create(store=secondary_store, file=fileobj)

        # _delete_files_from_stores should delete the file from multiple stores
        self.delete_expired_artifacts._delete_files_from_stores({fileobj})

        # And the file is gone...
        self.assertFalse(File.objects.filter(id=fileobj.id).exists())

    def test_delete_files_from_stores_file_was_re_added(self):
        """
        _delete_files_from_stores does not delete the file.

        The file exist in another Artifact: cannot be deleted.
        """
        fileobj, _ = File.get_or_create(
            hash_digest=b"19acff6019bb9ce73083b9df3b7a838cf47df117", size=1024
        )
        files_to_delete = [fileobj]

        artifact, _ = self.create_artifact()
        FileInArtifact.objects.create(artifact=artifact, file=fileobj)

        self.delete_expired_artifacts._delete_files_from_stores(files_to_delete)

        self.assertTrue(File.objects.filter(id=fileobj.id).exists())


class DeleteExpiredArtifactsTransactionTests(
    TestHelpersMixin, TransactionTestCase
):
    """Tests for DeleteExpiredArtifacts that require transactions."""

    def setUp(self):
        """Initialize test."""
        file_store, _ = FileStore.objects.get_or_create(
            name=DEFAULT_FILE_STORE_NAME, backend=FileStore.BackendChoices.LOCAL
        )
        self.workspace, _ = Workspace.objects.get_or_create(
            name=DEFAULT_WORKSPACE_NAME,
            defaults={"default_file_store": file_store},
        )

        self.artifact_1, _ = self.create_artifact(
            expiration_delay=1, paths=["README"], create_files=True
        )
        self.artifact_1.created_at = timezone.now() - timedelta(days=2)
        self.artifact_1.save()
        self.artifact_2, _ = self.create_artifact(expiration_delay=1)
        self.artifact_2.created_at = timezone.now() - timedelta(days=2)
        self.artifact_2.save()

        self.artifact_relation = self.create_artifact_relation(
            self.artifact_1, self.artifact_2
        )

        self.out = io.StringIO()
        self.err = io.StringIO()

        self.delete_expired_artifacts = DeleteExpiredArtifacts(
            out=self.out, err=self.err
        )

    def assert_delete_expired_artifacts_run_failed(
        self,
        *,
        expected_artifacts: int,
        expected_artifact_relations: int,
        run_success: bool,
        timeout: float,
        err: io.StringIO,
    ):
        """Assert result of deletion of artifacts when lock table failed."""
        self.assertEqual(Artifact.objects.all().count(), expected_artifacts)
        self.assertEqual(
            ArtifactRelation.objects.all().count(), expected_artifact_relations
        )
        self.assertFalse(run_success)

        self.assertEqual(
            err.getvalue(),
            f"Lock timed out ({timeout} seconds). Try again.\n",
        )

    def test_run_cannot_lock_artifact_relation_return_false(self):
        """
        run() cannot lock the tables of Artifact or ArtifactRelation.

        There is another transaction (created via RunInParallelTransaction).
        DeleteExpiredArtifacts.run() cannot lock the tables.
        """
        instances = [self.artifact_1, self.artifact_relation]

        for instance in instances:
            out = io.StringIO()
            err = io.StringIO()

            delete_expired_artifacts = DeleteExpiredArtifacts(out=out, err=err)
            delete_expired_artifacts._lock_timeout_secs = 0.1

            with self.subTest(instance._meta.object_name):
                expected_artifacts = Artifact.objects.all().count()
                expected_relations = ArtifactRelation.objects.all().count()

                manager = instance._meta.default_manager

                thread = RunInParallelTransaction(
                    lambda: manager.select_for_update().get(id=instance.id)
                )

                thread.start_transaction()

                success = delete_expired_artifacts.run(
                    verbosity=True, dry_run=False
                )

                thread.stop_transaction()

                self.assert_delete_expired_artifacts_run_failed(
                    expected_artifacts=expected_artifacts,
                    expected_artifact_relations=expected_relations,
                    run_success=success,
                    timeout=delete_expired_artifacts._lock_timeout_secs,
                    err=err,
                )

    @staticmethod
    def check_table_locked_for(manager: Manager[Any]):
        """
        Call manager.first(). Expect timeout.

        If it does not time out: raise self.failureException().
        """
        with connection.cursor() as cursor:
            cursor.execute("SET lock_timeout TO '0.1s';")

            try:
                with transaction.atomic():
                    manager.first()
            except OperationalError:
                table_was_locked = True
            else:
                table_was_locked = False  # pragma: no cover

        return table_was_locked

    def test_tables_locked_on_sweep(self):
        """Check relevant tables are locked when _sweep() is called."""

        def wait_for_check(*args, **kwargs):
            """Notify that _sweep() is called, wait for the check."""
            sweep_is_called.set()
            wait_for_check_tables_locked.wait()
            return []

        sweep_is_called = threading.Event()
        wait_for_check_tables_locked = threading.Event()

        patcher = mock.patch.object(
            self.delete_expired_artifacts, "_sweep", autospec=True
        )
        mocked_sweep = patcher.start()
        mocked_sweep.side_effect = wait_for_check
        self.addCleanup(patcher.stop)

        delete_expired_artifacts_run = RunInThreadAndCloseDBConnections(
            self.delete_expired_artifacts.run, verbosity=2, dry_run=False
        )
        delete_expired_artifacts_run.start_in_thread()

        sweep_is_called.wait()

        self.assertTrue(self.check_table_locked_for(Artifact.objects))
        self.assertTrue(self.check_table_locked_for(ArtifactRelation.objects))
        self.assertTrue(self.check_table_locked_for(FileInArtifact.objects))
        self.assertTrue(self.check_table_locked_for(FileInStore.objects))
        self.assertTrue(self.check_table_locked_for(File.objects))

        wait_for_check_tables_locked.set()

        delete_expired_artifacts_run.join()

    def test_on_commit_cleanup_delete_files(self):
        """File is deleted from the store if the transaction is committed."""
        artifact, _ = self.create_artifact(
            paths=["README"], create_files=True, expiration_delay=1
        )
        artifact.created_at = timezone.now() - timedelta(days=2)
        artifact.save()

        message_artifacts_deleted = _format_deleted_artifacts(
            Artifact.objects.all()
        )

        self.assertTrue(Artifact.objects.filter(id=artifact.id).exists())
        file_in_store = FileInStore.objects.first()
        fileobj = file_in_store.file
        store_backend = file_in_store.store.get_backend_object()

        file_path = store_backend.get_local_path(fileobj)
        self.assertTrue(file_path.exists())

        self.delete_expired_artifacts.run(verbosity=2, dry_run=False)

        self.assertFalse(Artifact.objects.filter(id=artifact.id).exists())

        # The file does not exist anymore
        self.assertFalse(file_path.exists())

        self.assertEqual(
            self.out.getvalue(),
            message_artifacts_deleted + "Deleting files from the store\n",
        )

    def test_deleting_files_artifact_table_is_locked(self):
        """Files get deleted while the Artifact table is still locked."""
        # Delete a relation and an artifact, and create an artifact
        # that expires tomorrow. This is to simplify the test
        self.artifact_relation.delete()
        self.artifact_2.delete()
        self.create_artifact(expiration_delay=1)

        patcher = mock.patch.object(
            self.delete_expired_artifacts,
            "_delete_files_from_stores",
            autospec=True,
        )
        delete_files_mocked = patcher.start()
        run_in_thread = RunInThreadAndCloseDBConnections(
            self.check_table_locked_for, Artifact.objects
        )
        delete_files_mocked.side_effect = (
            lambda file_objs: run_in_thread.run_and_wait()  # noqa: U100
        )
        self.addCleanup(patcher.stop)

        self.delete_expired_artifacts.run(verbosity=True, dry_run=False)

        delete_files_mocked.assert_called()

    def test_delete_files_from_stores_raise_artifact_is_deleted(self):
        """Artifact is deleted even if the file store deletion fail."""
        exception = RuntimeError
        patcher = mock.patch.object(
            self.delete_expired_artifacts,
            "_delete_files_from_stores",
            autospec=True,
        )
        delete_files_mocked = patcher.start()
        delete_files_mocked.side_effect = exception
        self.addCleanup(patcher.stop)

        try:
            self.delete_expired_artifacts.run(verbosity=False, dry_run=False)
        except exception:
            pass

        # _delete_files_from_stores raised an exception. Assert that the
        # artifact_2 deletion transaction is committed
        self.assertFalse(
            Artifact.objects.filter(id=self.artifact_2.id).exists()
        )


def _format_deleted_artifacts(artifacts: Iterable[Artifact]) -> str:
    message = ""

    for artifact in sorted(artifacts, key=lambda x: x.id):
        message += f"Deleted artifact {artifact.id}\n"

    return message
