Repository: learningequality/kolibri

Issue:
One group of the class with less students can view all the active lessons/exams in the class.
### Observed behavior

The lesson and exam active to one group must only be visible to that particular group. In this case while testing in the classroom, one group-Elephant, in the class had 12 students and they can view and solve all the active lessons and exams, while another group- Rest with 48 students is able to see lesson, exam only assigned to them.

### Expected behaviour

Exam, lesson only visible to the group it is assigned to.

### User-facing consequences

Students attempting wrong lesson, exam which the teacher created for other group.


### Steps to reproduce

1. Make 2 groups in the class, one with less students and other with more.
2. Assign different lessons and exams to the groups.
3. Login as student of smaller group and see whether all active the exams, lessons are visible?

Kolibri version : kolibri 0.9.0
Operating system : ubuntu 14.04
Browser : Chrome

### screenshots
![g2](https://user-images.githubusercontent.com/38091824/38655605-47ba6bfa-3e33-11e8-8c69-40835900c733.png)
![g3](https://user-images.githubusercontent.com/38091824/38655608-47eb4662-3e33-11e8-84d4-0fda715ddade.png)
![g4](https://user-images.githubusercontent.com/38091824/38655609-481a095c-3e33-11e8-9332-9aa32376d540.png)
![g5](https://user-images.githubusercontent.com/38091824/38655611-484a5008-3e33-11e8-95dd-4e71a74508e7.png)
![g6](https://user-images.githubusercontent.com/38091824/38655613-487bacf2-3e33-11e8-85e5-b28311988a7c.png)
![g7](https://user-images.githubusercontent.com/38091824/38655616-48ab8184-3e33-11e8-9ae5-f465d0d998ae.png)
![g8](https://user-images.githubusercontent.com/38091824/38655617-48e30258-3e33-11e8-8f61-351fcc5b8eae.png)
![g9](https://user-images.githubusercontent.com/38091824/38655619-4945444a-3e33-11e8-91f9-d8f7e5a287af.png)
![g10](https://user-images.githubusercontent.com/38091824/38655621-49a48da6-3e33-11e8-94a4-8e2d43f3c5d4.png)
![img_20180411_150105868](https://user-images.githubusercontent.com/38091824/38655914-be639afa-3e34-11e8-9122-c4c10538e824.jpg)
![img_20180411_150428547](https://user-images.githubusercontent.com/38091824/38655915-be98586c-3e34-11e8-93c8-e9241180c7c6.jpg)











Discussion:
@amit3107 I'm noticing in the first two pictures, that some students are in both "Elephant" and "Rest" groups. For example, I see "Ayesha" (the user in the last tablet photo) as well as "Anjali" are in both groups. Were the first two photos taken at the same time?

If Ayesha is in both "Rest" and "Elephant", would it make sense for Ayesha to see the Lessons and Exams we see in the last photo?

The Kolibri interface wants Coaches to place Learners in only one group in a classroom, but it is not impossible for them to be in multiple groups under special circumstances. 
Yes if Ayesha is in both the groups it makes sense for her to see the exams and lessons. But how was the coach able to place them in both the groups or is it allowed by Kolibri?

Under the hood, it's allowed by Kolibri - but the interface should be preventing it.
We have had issues in the past with adding/removing large numbers of Memberships (which is how we say a User is in a Group or Classroom "user is a member" = "user is enrolled or part of a group"). e.g. #2325.

We implemented a safer way to manipulate large numbers of Memberships, but it's possible issues still linger. Before we implemented this change, I would observe users being in two groups simultaneously, just as in the example here.
Hrm, does the interface allow this kind of error to be resolved? If a learner is in two groups, given the assumptions the interface makes about learners only being in one group, can this be undone?
I'm not sure if there's a foolproof way to resolve this on the interface. I think in the past, I would just move my learners around until the duplicate Memberships disappeared and they were in the right place...
For example, one might try moving "Ayesha in Elephant" to "Rest" and hopefully the end result is Ayesha is only in "Rest"
Here's one way to get 1 user in two different groups

1. In a class, create 3 groups, A, B, C
1. Place a User in Group A
1. Open a new browser tab for the Groups page ( User should be in group A)
1. In that second tab, move the user from group A to group B
1. In the original tab (don't refresh), move the user from group A to group C
1. User will be in groups B and C
It seems to me that the only way to prevent this is to impose a one-group-per-class constraint on the backend

Resolving patch:
diff --git a/kolibri/auth/serializers.py b/kolibri/auth/serializers.py
--- a/kolibri/auth/serializers.py
+++ b/kolibri/auth/serializers.py
@@ -6,6 +6,7 @@
 from rest_framework import serializers
 from rest_framework.validators import UniqueTogetherValidator
 
+from .constants.collection_kinds import LEARNERGROUP
 from .models import Classroom
 from .models import Facility
 from .models import FacilityDataset
@@ -69,6 +70,16 @@ class Meta:
         model = Membership
         fields = ('id', 'collection', 'user')
 
+    def create(self, validated_data):
+        user = validated_data["user"]
+        collection = validated_data["collection"]
+        if collection.kind == LEARNERGROUP and user.memberships.filter(collection__parent=collection.parent).exists():
+            # We are trying to create a membership for a user in a group, but they already belong to a group
+            # in the same class as this group. We may want to allow this, but the frontend does not currently
+            # support this. Error!
+            raise serializers.ValidationError('This user is already in a group in this class')
+        return super(MembershipSerializer, self).create(validated_data)
+
 
 class FacilityDatasetSerializer(serializers.ModelSerializer):
 


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
@@ -440,3 +440,43 @@ def test_delete_detail(self):
         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())
+
+
+class GroupMembership(APITestCase):
+
+    def setUp(self):
+        provision_device()
+        self.facility = FacilityFactory.create()
+        self.superuser = create_superuser(self.facility)
+        self.user = FacilityUserFactory.create(facility=self.facility)
+        self.classroom1 = ClassroomFactory.create(parent=self.facility)
+        self.classroom2 = ClassroomFactory.create(parent=self.facility)
+        self.lg11 = LearnerGroupFactory.create(parent=self.classroom1)
+        self.lg12 = LearnerGroupFactory.create(parent=self.classroom1)
+        self.lg21 = LearnerGroupFactory.create(parent=self.classroom2)
+        self.classroom1_membership = models.Membership.objects.create(collection=self.classroom1, user=self.user)
+        self.classroom2_membership = models.Membership.objects.create(collection=self.classroom2, user=self.user)
+        self.client.login(username=self.superuser.username, password=DUMMY_PASSWORD, facility=self.facility)
+
+    def test_create_group_membership_no_group_membership(self):
+        url = reverse('membership-list')
+        response = self.client.post(url, {'user': self.user.id, 'collection': self.lg11.id})
+        self.assertEqual(response.status_code, 201)
+
+    def test_create_group_membership_group_membership_other_class(self):
+        models.Membership.objects.create(user=self.user, collection=self.lg21)
+        url = reverse('membership-list')
+        response = self.client.post(url, {'user': self.user.id, 'collection': self.lg11.id})
+        self.assertEqual(response.status_code, 201)
+
+    def test_create_group_membership_group_membership_same_class(self):
+        models.Membership.objects.create(user=self.user, collection=self.lg12)
+        url = reverse('membership-list')
+        response = self.client.post(url, {'user': self.user.id, 'collection': self.lg11.id})
+        self.assertEqual(response.status_code, 400)
+
+    def test_create_class_membership_group_membership_different_class(self):
+        self.classroom2_membership.delete()
+        url = reverse('membership-list')
+        response = self.client.post(url, {'user': self.user.id, 'collection': self.classroom2.id})
+        self.assertEqual(response.status_code, 201)