Repository: learningequality/kolibri

Issue:
Removing a Learner from a Classroom does not remove them from Learner Groups
Observed behavior

Removing a Learner from a Classroom does not remove them from Learner Groups

e.g. 

1. Enroll a Learner in Classroom C and Learner Group LG (inside of C)
1. Remove Learner from Classroom C
1. Learner still has Membership in Learner Group LG

This messes up the logic of the Learn page navbar because the Learner will technically still have 1 membership (in LG), and the "Classes" (formerly "Exams") tab will still show.

### Expected behavior

Learner loses membership in LG if they lose membership in C.


### User-facing consequences

"Classes"/Exam tab shows when it shouldn't. Maybe other undesired side-effects.




Discussion:
Also, the Membership API filters (?collection, ?user, etc.) don't work. Fixed them locally by adding more fields to the MembershipFilter:

```
class MembershipFilter(FilterSet):
    user_ids = CharFilter(method="filter_user_ids")

    def filter_user_ids(self, queryset, name, value):
        return queryset.filter(user_id__in=value.split(','))

    class Meta:
        model = Membership
        fields = ('user', 'collection', 'user_ids',)

```
The above comment is problematic because the logic for deleting a classroom membership relies on these filters. When I tested this, removing a single learner from a classroom wipes out all memberships everywhere. (But on the UI, this isn't immediately noticed until you refresh the Class Edit Page).

Resolving patch:
diff --git a/kolibri/auth/apps.py b/kolibri/auth/apps.py
--- a/kolibri/auth/apps.py
+++ b/kolibri/auth/apps.py
@@ -1,4 +1,6 @@
-from __future__ import absolute_import, print_function, unicode_literals
+from __future__ import absolute_import
+from __future__ import print_function
+from __future__ import unicode_literals
 
 from django.apps import AppConfig
 
@@ -7,3 +9,6 @@ class KolibriAuthConfig(AppConfig):
     name = 'kolibri.auth'
     label = 'kolibriauth'
     verbose_name = 'Kolibri Auth'
+
+    def ready(self):
+        from .signals import cascade_delete_membership  # noqa: F401
diff --git a/kolibri/auth/signals.py b/kolibri/auth/signals.py
new file mode 100644
--- /dev/null
+++ b/kolibri/auth/signals.py
@@ -0,0 +1,13 @@
+from django.db.models.signals import pre_delete
+from django.dispatch import receiver
+
+from .models import Membership
+
+
+@receiver(pre_delete, sender=Membership)
+def cascade_delete_membership(sender, instance=None, *args, **kwargs):
+    """
+    For a given membership instance and the collection associated with it,
+    we delete all membership objects whose collection is a child of the instance's collection.
+    """
+    Membership.objects.filter(collection__parent_id=instance.collection_id).delete()


Test patch:
diff --git a/kolibri/auth/test/test_api.py b/kolibri/auth/test/test_api.py
--- a/kolibri/auth/test/test_api.py
+++ b/kolibri/auth/test/test_api.py
@@ -417,3 +417,28 @@ def test_facility_user_cannot_delete_dataset(self):
         self.client.login(username=self.user.username, password=DUMMY_PASSWORD)
         response = self.client.delete(reverse('facilitydataset-detail', kwargs={'pk': self.facility.dataset_id}), format="json")
         self.assertEqual(response.status_code, 403)
+
+
+class MembershipCascadeDeletion(APITestCase):
+
+    def setUp(self):
+        provision_device()
+        self.facility = FacilityFactory.create()
+        self.superuser = create_superuser(self.facility)
+        self.user = FacilityUserFactory.create(facility=self.facility)
+        self.classroom = ClassroomFactory.create(parent=self.facility)
+        self.lg = LearnerGroupFactory.create(parent=self.classroom)
+        self.classroom_membership = models.Membership.objects.create(collection=self.classroom, user=self.user)
+        models.Membership.objects.create(collection=self.lg, user=self.user)
+
+    def test_delete_classroom_membership(self):
+        url = reverse('membership-list') + "?user={}&collection={}".format(self.user.id, self.classroom.id)
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, 204)
+        self.assertFalse(models.Membership.objects.all().exists())
+
+    def test_delete_detail(self):
+        self.client.login(username=self.superuser.username, password=DUMMY_PASSWORD, facility=self.facility)
+        response = self.client.delete(reverse('membership-detail', kwargs={'pk': self.classroom_membership.id}))
+        self.assertEqual(response.status_code, 204)
+        self.assertFalse(models.Membership.objects.all().exists())