Repository: python-discord/site

Issue:
User's role field needs to have a cascade delete
#357 replaced the FK with an `ArrayField`. This introduced a problem of the roles table falling out of sync with the users table. I encountered an issue when trying to sync a user with the bot. One of their roles had been deleted from the roles table, but it was still present in their roles field. The validator ensures all roles exist in the table, so the request was failing.

Apparently [Django Postgres Extensions](https://django-postgres-extensions.readthedocs.io/en/latest/arrays.html) have a solution for this.


Discussion:
Django Postgres Extensions doesn't seem to be maintained any more and may be incompatible with Django 3.0.
I believe, as a temporary workaround (until the migration to FastAPI) this could be solved by using a `pre_delete` signal, that unassigns the Role assigned to the User's ArrayField, then performs the process as usual.
(That's purely a suggestion, I faced the same issue in a previous code jam)

Resolving patch:
diff --git a/pydis_site/apps/api/__init__.py b/pydis_site/apps/api/__init__.py
--- a/pydis_site/apps/api/__init__.py
+++ b/pydis_site/apps/api/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'pydis_site.apps.api.apps.ApiConfig'
diff --git a/pydis_site/apps/api/apps.py b/pydis_site/apps/api/apps.py
--- a/pydis_site/apps/api/apps.py
+++ b/pydis_site/apps/api/apps.py
@@ -4,4 +4,12 @@
 class ApiConfig(AppConfig):
     """Django AppConfig for the API app."""
 
-    name = 'api'
+    name = 'pydis_site.apps.api'
+
+    def ready(self) -> None:
+        """
+        Gets called as soon as the registry is fully populated.
+
+        https://docs.djangoproject.com/en/3.2/ref/applications/#django.apps.AppConfig.ready
+        """
+        import pydis_site.apps.api.signals  # noqa: F401
diff --git a/pydis_site/apps/api/signals.py b/pydis_site/apps/api/signals.py
new file mode 100644
--- /dev/null
+++ b/pydis_site/apps/api/signals.py
@@ -0,0 +1,12 @@
+from django.db.models.signals import post_delete
+from django.dispatch import receiver
+
+from pydis_site.apps.api.models.bot import Role, User
+
+
+@receiver(signal=post_delete, sender=Role)
+def delete_role_from_user(sender: Role, instance: Role, **kwargs) -> None:
+    """Unassigns the Role (instance) that is being deleted from every user that has it."""
+    for user in User.objects.filter(roles__contains=[instance.id]):
+        del user.roles[user.roles.index(instance.id)]
+        user.save()


Test patch:
diff --git a/pydis_site/apps/api/tests/test_roles.py b/pydis_site/apps/api/tests/test_roles.py
--- a/pydis_site/apps/api/tests/test_roles.py
+++ b/pydis_site/apps/api/tests/test_roles.py
@@ -1,7 +1,7 @@
 from django.urls import reverse
 
 from .base import AuthenticatedAPITestCase
-from ..models import Role
+from ..models import Role, User
 
 
 class CreationTests(AuthenticatedAPITestCase):
@@ -35,6 +35,20 @@ def setUpTestData(cls):
             permissions=6,
             position=0,
         )
+        cls.role_to_delete = Role.objects.create(
+            id=7,
+            name="role to delete",
+            colour=7,
+            permissions=7,
+            position=0,
+        )
+        cls.role_unassigned_test_user = User.objects.create(
+            id=8,
+            name="role_unassigned_test_user",
+            discriminator="0000",
+            roles=[cls.role_to_delete.id],
+            in_guild=True
+        )
 
     def _validate_roledict(self, role_dict: dict) -> None:
         """Helper method to validate a dict representing a role."""
@@ -81,11 +95,11 @@ def test_role_list(self):
         url = reverse('api:bot:role-list')
 
         response = self.client.get(url)
-        self.assertContains(response, text="id", count=4, status_code=200)
+        self.assertContains(response, text="id", count=5, status_code=200)
 
         roles = response.json()
         self.assertIsInstance(roles, list)
-        self.assertEqual(len(roles), 4)
+        self.assertEqual(len(roles), 5)
 
         for role in roles:
             self._validate_roledict(role)
@@ -181,6 +195,12 @@ def test_role_delete_200(self):
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 204)
 
+    def test_role_delete_unassigned(self):
+        """Tests if the deleted Role gets unassigned from the user."""
+        self.role_to_delete.delete()
+        self.role_unassigned_test_user.refresh_from_db()
+        self.assertEqual(self.role_unassigned_test_user.roles, [])
+
     def test_role_detail_404_all_methods(self):
         """Tests detail view with non-existing ID."""
         url = reverse('api:bot:role-detail', args=(20190815,))