Repository: learningequality/studio
Issue: Invites are deleted as expected, but then replaced by a copy of itself

## Summary

When someone is invited to a channel, we make a `contentcuration_invitaitions` entry.

When that invitation is accepted, we give the proper user permissions per the invitation and then we delete the row we created in `contentcuration_invitations`.

That works as it should.

But then - the exact same invitation is created anew. I cannot figure out why and it makes me sad.

```
/* Here we delete the invited person from the channel_viewers table */

2020-10-01 06:34:14.025 PDT [18645] learningequality@kolibri-studio LOG:  statement: DELETE FROM "contentcuration_channel_viewers" WHERE ("contentcuration_channel_viewers"."channel_id" = '0399d2f6f4dd4e48ad167a9390648d30' AND "contentcuration_channel_viewers"."user_id" IN (8))


/* Get editor user id... */

2020-10-01 06:34:14.026 PDT [18645] learningequality@kolibri-studio LOG:  statement: COMMIT
2020-10-01 06:34:14.027 PDT [18645] learningequality@kolibri-studio LOG:  statement: BEGIN
2020-10-01 06:34:14.028 PDT [18645] learningequality@kolibri-studio LOG:  statement: SELECT "contentcuration_channel_editors"."user_id" FROM "contentcuration_channel_editors" WHERE ("contentcuration_channel_editors"."channel_id" = '0399d2f6f4dd4e48ad167a9390648d30' AND "contentcuration_channel_editors"."user_id" IN (8))

/* Here we delete the invitation we accepted! */

2020-10-01 06:34:14.028 PDT [18645] learningequality@kolibri-studio LOG:  statement: COMMIT
2020-10-01 06:34:14.029 PDT [18645] learningequality@kolibri-studio LOG:  statement: BEGIN
2020-10-01 06:34:14.029 PDT [18645] learningequality@kolibri-studio LOG:  statement: DELETE FROM "contentcuration_invitation" WHERE "contentcuration_invitation"."id" IN ('f121eee11f1b4958b58ee0ac0ee3b82e')

/* Here we for some reason make a new one milliseconds after we deleted! */

2020-10-01 06:34:14.029 PDT [18645] learningequality@kolibri-studio LOG:  statement: COMMIT
2020-10-01 06:34:14.031 PDT [18645] learningequality@kolibri-studio LOG:  statement: BEGIN
2020-10-01 06:34:14.032 PDT [18645] learningequality@kolibri-studio LOG:  statement: UPDATE "contentcuration_invitation" SET "invited_id" = 8, "share_mode" = 'edit', "email" = 'ivanbot@leq.org', "sender_id" = 1, "channel_id" = '0399d2f6f4dd4e48ad167a9390648d30', "first_name" = 'Ivan', "last_name" = 'NeoBot' WHERE "contentcuration_invitation"."id" = '771ef13ada6a4b6db5f05540a4c3770f'

2020-10-01 06:34:14.032 PDT [18645] learningequality@kolibri-studio LOG:  statement: INSERT INTO "contentcuration_invitation" ("id", "invited_id", "share_mode", "email", "sender_id", "channel_id", "first_name", "last_name") VALUES ('771ef13ada6a4b6db5f05540a4c3770f', 8, 'edit', 'ivanbot@leq.org', 1, '0399d2f6f4dd4e48ad167a9390648d30', 'Ivan', 'NeoBot')
```

## How to reproduce

1. Make an account and make a channel then invite another account.
2. Go to that account in incognito (or same browser just clear your indexed db) and accept the invitation.

`invitation.py` is where we define the ViewSet and Serializer for invitations. In the `Serializer#update()` method we delete the instance. I feel like something in here or in the Invitation model has to be the culprit. Possibly some kind of `onDelete` hooks/callbacks on the Inivtation or some related model?


Changed files:
contentcuration/contentcuration/tests/viewsets/test_invitation.py, contentcuration/contentcuration/viewsets/invitation.py

diff --git a/contentcuration/contentcuration/tests/viewsets/test_invitation.py b/contentcuration/contentcuration/tests/viewsets/test_invitation.py
index 49e176edd..087012f37 100644
--- a/contentcuration/contentcuration/tests/viewsets/test_invitation.py
+++ b/contentcuration/contentcuration/tests/viewsets/test_invitation.py
@@ -95,6 +95,11 @@ class SyncTestCase(StudioAPITestCase):
         except models.Invitation.DoesNotExist:
             pass
         self.assertTrue(self.channel.editors.filter(pk=self.invited_user.id).exists())
+        self.assertFalse(
+            models.Invitation.objects.filter(
+                email=self.invited_user.email, channel=self.channel
+            ).exists()
+        )
 
     def test_update_invitation_decline(self):
 
@@ -113,6 +118,11 @@ class SyncTestCase(StudioAPITestCase):
         except models.Invitation.DoesNotExist:
             pass
         self.assertFalse(self.channel.editors.filter(pk=self.invited_user.id).exists())
+        self.assertFalse(
+            models.Invitation.objects.filter(
+                email=self.invited_user.email, channel=self.channel
+            ).exists()
+        )
 
     def test_update_invitation_empty(self):
 
@@ -240,7 +250,7 @@ class CRUDTestCase(StudioAPITestCase):
             {"accepted": True},
             format="json",
         )
-        self.assertEqual(response.status_code, 200, response.content)
+        self.assertEqual(response.status_code, 204, response.content)
         try:
             models.Invitation.objects.get(id=invitation.id)
             self.fail("Invitation was not deleted")
@@ -248,6 +258,11 @@ class CRUDTestCase(StudioAPITestCase):
             pass
 
         self.assertTrue(self.channel.editors.filter(pk=self.invited_user.id).exists())
+        self.assertFalse(
+            models.Invitation.objects.filter(
+                email=self.invited_user.email, channel=self.channel
+            ).exists()
+        )
 
     def test_update_invitation_decline(self):
 
@@ -259,13 +274,18 @@ class CRUDTestCase(StudioAPITestCase):
             {"declined": True},
             format="json",
         )
-        self.assertEqual(response.status_code, 200, response.content)
+        self.assertEqual(response.status_code, 204, response.content)
         try:
             models.Invitation.objects.get(id=invitation.id)
             self.fail("Invitation was not deleted")
         except models.Invitation.DoesNotExist:
             pass
         self.assertFalse(self.channel.editors.filter(pk=self.invited_user.id).exists())
+        self.assertFalse(
+            models.Invitation.objects.filter(
+                email=self.invited_user.email, channel=self.channel
+            ).exists()
+        )
 
     def test_update_invitation_empty(self):
 
diff --git a/contentcuration/contentcuration/viewsets/invitation.py b/contentcuration/contentcuration/viewsets/invitation.py
index 7c8bf5cbd..73b7dee7c 100644
--- a/contentcuration/contentcuration/viewsets/invitation.py
+++ b/contentcuration/contentcuration/viewsets/invitation.py
@@ -3,6 +3,8 @@ from django_filters.rest_framework import DjangoFilterBackend
 from django_filters.rest_framework import FilterSet
 from rest_framework import serializers
 from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+from rest_framework.status import HTTP_204_NO_CONTENT
 
 from contentcuration.models import Channel
 from contentcuration.models import Invitation
@@ -115,3 +117,26 @@ class InvitationViewSet(ValuesViewset):
         "channel": "channel_id",
         "accepted": False,
     }
+
+    def perform_update(self, serializer):
+        instance = serializer.save()
+        # We are using update for accepted and declined
+        # both of which result in the model being deleted
+        # if it has been deleted, then the model will have had
+        # its id cleared. Check here and don't do save
+        # in order to prevent a new model being created
+        if instance.id is not None:
+            instance.save()
+            serializer.post_save_update(instance)
+
+    def update(self, request, *args, **kwargs):
+        partial = kwargs.pop("partial", False)
+        instance = self.get_edit_object()
+        serializer = self.get_serializer(instance, data=request.data, partial=partial)
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+
+        if instance.id is not None:
+
+            return Response(self.serialize_object(instance.id))
+        return Response(status=HTTP_204_NO_CONTENT)