Repository: akvo/akvo-rsr

Issue:
Bug: Cannot delete projects anymore
### What were you doing?

Ran `./manage.py delete_project --delete 9023` and got a long exception stack.

### What should've happened?

The project should've been deleted without an hiccup.

### My environment

_No response_

### Additional context

Cut logs
```
...
INFO 2022-06-21 15:38:26,003 project Generated thumbs for 350x200
DEBUG 2022-06-21 15:38:26,014 project Deleted 0 thumbs with old URLs
...
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/django/db/backends/base/base.py", line 242, in _commit
    return self.connection.commit()
psycopg2.errors.ForeignKeyViolation: insert or update on table "rsr_projectthumbnail" violates foreign key constraint "rsr_projectthumbnail_project_id_882058f1_fk_rsr_project_id"
DETAIL:  Key (project_id)=(9023) is not present in table "rsr_project".

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "./manage.py", line 27, in <module>
    execute_from_command_line(sys.argv)
  File "/usr/local/lib/python3.8/site-packages/django/core/management/__init__.py", line 419, in execute_from_command_line
    utility.execute()
  File "/usr/local/lib/python3.8/site-packages/django/core/management/__init__.py", line 413, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/usr/local/lib/python3.8/site-packages/django/core/management/base.py", line 354, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/usr/local/lib/python3.8/site-packages/django/core/management/base.py", line 398, in execute
    output = self.handle(*args, **options)
  File "/var/akvo/rsr/code/akvo/rsr/management/commands/delete_project.py", line 35, in handle
    project.delete()
  File "/var/akvo/rsr/code/akvo/rsr/models/project.py", line 476, in delete
    return super(Project, self).delete(using=using, keep_parents=keep_parents)
  File "/usr/local/lib/python3.8/site-packages/django/db/models/base.py", line 967, in delete
    return collector.delete()
  File "/usr/local/lib/python3.8/site-packages/django/db/models/deletion.py", line 435, in delete
    signals.post_delete.send(
  File "/usr/local/lib/python3.8/site-packages/django/db/transaction.py", line 246, in __exit__
    connection.commit()
  File "/usr/local/lib/python3.8/site-packages/django/utils/asyncio.py", line 33, in inner
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/django/db/backends/base/base.py", line 266, in commit
    self._commit()
  File "/usr/local/lib/python3.8/site-packages/django/db/backends/base/base.py", line 242, in _commit
    return self.connection.commit()
  File "/usr/local/lib/python3.8/site-packages/django/db/utils.py", line 90, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/usr/local/lib/python3.8/site-packages/django/db/backends/base/base.py", line 242, in _commit
    return self.connection.commit()
django.db.utils.IntegrityError: insert or update on table "rsr_projectthumbnail" violates foreign key constraint "rsr_projectthumbnail_project_id_882058f1_fk_rsr_project_id"
DETAIL:  Key (project_id)=(9023) is not present in table "rsr_project".
```

It looks like the `on_save` signal was called for some reason while deleting the project which triggers trying to create a thumbnail. The signal shouldn't actually be called by django when deleting an object.


Discussion:

Resolving patch:
diff --git a/akvo/rsr/models/project.py b/akvo/rsr/models/project.py
--- a/akvo/rsr/models/project.py
+++ b/akvo/rsr/models/project.py
@@ -61,6 +61,13 @@
     'project_plan_summary', 'goals_overview', 'background', 'current_status', 'target_group',
     'project_plan', 'sustainability']
 
+"""
+Projects in the process of being deleted
+Some signals attempt to update projects and they shouldn't attempt to do so
+ when a project is being deleted
+"""
+DELETION_SET = set()
+
 
 def get_default_descriptions_order():
     return DESCRIPTIONS_ORDER
@@ -472,8 +479,13 @@ def delete(self, using=None, keep_parents=False):
         # Delete results on the project, before trying to delete the project,
         # since the RelatedProject object on the project refuses to get deleted
         # if there are existing results, causing the delete to raise 500s
-        self.results.all().delete()
-        return super(Project, self).delete(using=using, keep_parents=keep_parents)
+        if self.pk:
+            DELETION_SET.add(self.pk)
+        try:
+            self.results.all().delete()
+            return super(Project, self).delete(using=using, keep_parents=keep_parents)
+        finally:
+            DELETION_SET.discard(self.pk)
 
     def save(self, *args, **kwargs):
         # Strip title of any trailing or leading spaces
@@ -494,6 +506,9 @@ def save(self, *args, **kwargs):
 
         orig, orig_aggregate_children, orig_aggregate_to_parent = None, None, None
         if self.pk:
+            # If the project is being deleted, don't allow saving it
+            if self.pk in DELETION_SET:
+                return
             orig = Project.objects.get(pk=self.pk)
 
             # Update funds and funds_needed if donations change.  Any other


Test patch:
diff --git a/akvo/rsr/tests/models/test_project.py b/akvo/rsr/tests/models/test_project.py
--- a/akvo/rsr/tests/models/test_project.py
+++ b/akvo/rsr/tests/models/test_project.py
@@ -4,8 +4,10 @@
 # See more details in the license.txt file located at the root folder of the Akvo RSR module.
 # For additional details on the GNU license please see < http://www.gnu.org/licenses/agpl.html >.
 from datetime import date
+from unittest.mock import patch
 
 from django.contrib.auth import get_user_model
+from django.db import models
 from django.test import TestCase
 
 from akvo.rsr.tests.base import BaseTestCase
@@ -36,10 +38,6 @@ def setUp(self):
             label='label 2'
         )
 
-    def tearDown(self):
-        Project.objects.all().delete()
-        Organisation.objects.all().delete()
-
     def test_project_last_update(self):
         """Test that Project.last_update is updated correctly when an update is deleted.
 
@@ -187,6 +185,41 @@ def test_use_project_roles_changes_when_reporting_partner_uses_it(self):
         self.assertTrue(project.use_project_roles)
 
 
+class ProjectDeleteTestCase(BaseTestCase):
+    """
+    When deleting a project all calls to the save method should simply return
+    without triggering an SQL request
+    """
+
+    def setUp(self):
+        self.project = Project.objects.create(title="Test Project")
+
+    def _test_project_delete(self):
+        """
+        Ensure that when a project is being deleted, that the super(Project).save() isn't called
+        """
+        real_save = models.Model.save
+
+        def mock_save(_self, *args, **kwargs):
+            self.assertNotIsInstance(_self, Project)
+            real_save(_self, *args, **kwargs)
+
+        with patch.object(models.Model, "save", mock_save):
+            self.project.delete()
+        self.assertFalse(Project.objects.filter(id=self.project.id).exists())
+
+    def test_with_project_update(self):
+        ProjectUpdate.objects.create(
+            title="Test update 1", project=self.project,
+            user=get_user_model().objects.create(email='user1@com.com', username="somedude")
+        )
+        self._test_project_delete()
+
+    def test_with_budget_item(self):
+        BudgetItem.objects.create(project=self.project, amount=100,)
+        self._test_project_delete()
+
+
 class ProjectHierarchyTestCase(TestCase):
     """Tests for the project model"""
 
@@ -230,9 +263,6 @@ def setUp(self):
         #      \
         #       5
 
-    def tearDown(self):
-        Project.objects.all().delete()
-
     def test_project_descendants(self):
         # Note that descendants includes self
         descendants_p1 = self.project1.descendants()