Repository: learningequality/kolibri

Issue:
Edge cases when deleting all recipient groups for a lesson (and probably quizzes)
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

### Observed behavior
Setup:

1. Create a Lesson and assign to a group
1. Delete that group. Now the Lesson is literally assigned to nobody.

**Problem 1: Reports incorrectly say lesson recipient is "Entire class"**

On Plan page: "Assigned to nobody" has no more recipients

![Screen Shot 2019-05-07 at 4 56 48 PM](https://user-images.githubusercontent.com/10248067/57340218-b6518f00-70e9-11e9-999b-8c74bb32395e.png)

On Report page: "Assigned to nobody" is assigned to whole class

![Screen Shot 2019-05-07 at 4 56 54 PM](https://user-images.githubusercontent.com/10248067/57340254-c8cbc880-70e9-11e9-8ad4-44abfe5d85c5.png)

**Problem 2: Saving a "No one" quiz with "Entire class" doesn't fix it**

Steps:

1. Get a "no one" lesson by assigning a lesson to a group, then deleting that group
1. Go to the "Edit details" page for the lesson. The "Entire class" option will be selected under recipients
1. Save the lesson
1. The lesson will still be assigned to nobody.


### Expected behavior

1. Problem 1: The report version of lesson accurately conveys that the lesson is assigned to nobody.
1. Problem 2: Saving a quiz with the "Entire class" option selected will re-save quiz and assign it to the whole class. (Alternatively: a quiz being assigned to no one is an option, with its own form control).

### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->

…

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/kolibri.log
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->

…

### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context

Version 0.12 develop


Discussion:
@bjester mind taking a look at this one?
@indirectlylit Will do
Classes are being filtered out of the API response, so in some cases, the front-end doesn't have enough info to determine whether or not the lesson or quiz is assigned to the class or no one.

Resolving patch:
diff --git a/kolibri/plugins/coach/class_summary_api.py b/kolibri/plugins/coach/class_summary_api.py
--- a/kolibri/plugins/coach/class_summary_api.py
+++ b/kolibri/plugins/coach/class_summary_api.py
@@ -183,14 +183,24 @@ class LessonSerializer(serializers.ModelSerializer):
     active = serializers.BooleanField(source="is_active")
     node_ids = serializers.SerializerMethodField()
 
-    # classrooms are in here, and filtered out later
-    groups = LessonAssignmentsField(
+    # classrooms are in here, and filtered out later to create `groups`
+    assignments = LessonAssignmentsField(
         many=True, read_only=True, source="lesson_assignments"
     )
 
+    groups = serializers.ListField(default=[])
+
     class Meta:
         model = Lesson
-        fields = ("id", "title", "active", "node_ids", "groups", "description")
+        fields = (
+            "id",
+            "title",
+            "active",
+            "node_ids",
+            "assignments",
+            "groups",
+            "description",
+        )
 
     def get_node_ids(self, obj):
         return [resource["contentnode_id"] for resource in obj.resources]
@@ -210,8 +220,10 @@ class ExamSerializer(serializers.ModelSerializer):
 
     question_sources = ExamQuestionSourcesField(default=[])
 
-    # classes are in here, and filtered out later
-    groups = ExamAssignmentsField(many=True, read_only=True, source="assignments")
+    # classes are in here, and filtered out later to create `groups`
+    assignments = ExamAssignmentsField(many=True, read_only=True)
+
+    groups = serializers.ListField(default=[])
 
     class Meta:
         model = Exam
@@ -220,6 +232,7 @@ class Meta:
             "title",
             "active",
             "question_sources",
+            "assignments",
             "groups",
             "data_model_version",
             "question_count",
@@ -273,11 +286,11 @@ def retrieve(self, request, pk):
 
         # filter classes out of exam assignments
         for exam in exam_data:
-            exam["groups"] = [g for g in exam["groups"] if g != pk]
+            exam["groups"] = [g for g in exam["assignments"] if g != pk]
 
         # filter classes out of lesson assignments
         for lesson in lesson_data:
-            lesson["groups"] = [g for g in lesson["groups"] if g != pk]
+            lesson["groups"] = [g for g in lesson["assignments"] if g != pk]
 
         all_node_ids = set()
         for lesson in lesson_data:


Test patch:
diff --git a/kolibri/plugins/coach/assets/src/modules/classSummary/__test__/dataHelpers.spec.js b/kolibri/plugins/coach/assets/src/modules/classSummary/__test__/dataHelpers.spec.js
--- a/kolibri/plugins/coach/assets/src/modules/classSummary/__test__/dataHelpers.spec.js
+++ b/kolibri/plugins/coach/assets/src/modules/classSummary/__test__/dataHelpers.spec.js
@@ -41,6 +41,62 @@ describe('coach summary data helpers', () => {
       ]);
     });
   });
+  describe('getLearnersForExam', () => {
+    it('returns empty when exam has no assignments', () => {
+      expect(
+        store.getters.getLearnersForExam({
+          assignments: [],
+        })
+      ).toEqual([]);
+    });
+
+    it('passes through to getLearnersForGroups when exam has assignments', () => {
+      const groups = ['group_id_2', 'group_id_3'];
+      const output = store.getters.getLearnersForExam({
+        assignments: groups,
+        groups,
+      });
+      output.sort();
+      expect(output).toEqual([
+        'learner_id_1',
+        'learner_id_11',
+        'learner_id_2',
+        'learner_id_5',
+        'learner_id_6',
+        'learner_id_7',
+        'learner_id_8',
+        'learner_id_9',
+      ]);
+    });
+  });
+  describe('getLearnersForLesson', () => {
+    it('returns empty when lesson has no assignments', () => {
+      expect(
+        store.getters.getLearnersForLesson({
+          assignments: [],
+        })
+      ).toEqual([]);
+    });
+
+    it('passes through to getLearnersForGroups when lesson has assignments', () => {
+      const groups = ['group_id_2', 'group_id_3'];
+      const output = store.getters.getLearnersForLesson({
+        assignments: groups,
+        groups,
+      });
+      output.sort();
+      expect(output).toEqual([
+        'learner_id_1',
+        'learner_id_11',
+        'learner_id_2',
+        'learner_id_5',
+        'learner_id_6',
+        'learner_id_7',
+        'learner_id_8',
+        'learner_id_9',
+      ]);
+    });
+  });
   describe('getContentStatusObjForLearner', () => {
     it('returns a recorded status object given a content ID and learner ID', () => {
       expect(store.getters.getContentStatusObjForLearner('content_Q', 'learner_id_1')).toEqual({
diff --git a/kolibri/plugins/coach/assets/test/views/reports/ReportsExerciseLearners.spec.js b/kolibri/plugins/coach/assets/test/views/reports/ReportsExerciseLearners.spec.js
--- a/kolibri/plugins/coach/assets/test/views/reports/ReportsExerciseLearners.spec.js
+++ b/kolibri/plugins/coach/assets/test/views/reports/ReportsExerciseLearners.spec.js
@@ -9,6 +9,7 @@ const entries = [
     name: 'learner1',
     username: 'learner1',
     groups: [{ id: 'dc2', name: 'group1' }, { id: '23s', name: 'group2' }],
+    assignments: [{ id: 'dc2', name: 'group1' }, { id: '23s', name: 'group2' }],
     exerciseLearnerLink: '#/2e3/reports/lessons/79b/exercises/a97/learners/d4b',
     statusObj: {
       learner_id: 'd4b',
@@ -23,6 +24,7 @@ const entries = [
     name: 'learner2',
     username: 'learner2',
     groups: [{ id: '23s', name: 'group2' }],
+    assignments: [{ id: '23s', name: 'group2' }],
     exerciseLearnerLink: '#/2e3/reports/lessons/79b/exercises/a97/learners/a5d',
     statusObj: {
       learner_id: 'a5d',
diff --git a/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonExerciseLearnerListPage.spec.js b/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonExerciseLearnerListPage.spec.js
--- a/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonExerciseLearnerListPage.spec.js
+++ b/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonExerciseLearnerListPage.spec.js
@@ -35,6 +35,12 @@ const LEARNER_3 = {
   name: 'learner3 name',
 };
 
+const CLASSROOM = {
+  id: 'classroom',
+  name: 'classroom',
+  member_ids: [LEARNER_1.id, LEARNER_2.id, LEARNER_3.id],
+};
+
 const GROUP_1 = {
   id: 'group1',
   name: 'group1 name',
@@ -102,6 +108,7 @@ const initWrapper = lessonMap => {
     lessonMap = {
       [LESSON_ID]: {
         groups: [],
+        assignments: [CLASSROOM],
       },
     };
   }
@@ -202,6 +209,31 @@ describe('ReportsLessonExerciseLearnerListPage', () => {
     expect(wrapper.vm.$route.query.groups).toBeUndefined();
   });
 
+  describe('for a lesson without assignments', () => {
+    let lessonMap;
+
+    beforeEach(() => {
+      lessonMap = {
+        [LESSON_ID]: {
+          groups: [],
+          assignments: [],
+        },
+      };
+    });
+
+    describe('when displaying all learners', () => {
+      beforeEach(() => {
+        wrapper = initWrapper(lessonMap);
+      });
+
+      it('renders no class learners', () => {
+        expect(wrapper.html()).not.toContain(LEARNER_1.name);
+        expect(wrapper.html()).not.toContain(LEARNER_2.name);
+        expect(wrapper.html()).not.toContain(LEARNER_3.name);
+      });
+    });
+  });
+
   describe('for an entire class lesson', () => {
     let lessonMap;
 
@@ -209,6 +241,7 @@ describe('ReportsLessonExerciseLearnerListPage', () => {
       lessonMap = {
         [LESSON_ID]: {
           groups: [],
+          assignments: [CLASSROOM],
         },
       };
     });
@@ -260,13 +293,14 @@ describe('ReportsLessonExerciseLearnerListPage', () => {
     });
   });
 
-  describe('for a lesson restricted to some groups recipents only', () => {
+  describe('for a lesson restricted to some groups recipients only', () => {
     let lessonMap;
 
     beforeEach(() => {
       lessonMap = {
         [LESSON_ID]: {
           groups: [GROUP_2.id, GROUP_3.id],
+          assignments: [GROUP_2.id, GROUP_3.id],
         },
       };
     });
diff --git a/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonResourceLearnerListPage.spec.js b/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonResourceLearnerListPage.spec.js
--- a/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonResourceLearnerListPage.spec.js
+++ b/kolibri/plugins/coach/assets/test/views/reports/ReportsLessonResourceLearnerListPage.spec.js
@@ -37,6 +37,12 @@ const LEARNER_3 = {
   name: 'learner3 name',
 };
 
+const CLASSROOM = {
+  id: 'classroom',
+  name: 'classroom',
+  member_ids: [LEARNER_1.id, LEARNER_2.id, LEARNER_3.id],
+};
+
 const GROUP_1 = {
   id: 'group1',
   name: 'group1 name',
@@ -112,6 +118,7 @@ const initWrapper = lessonMap => {
     lessonMap = {
       [LESSON_ID]: {
         groups: [],
+        assignments: [CLASSROOM],
       },
     };
   }
@@ -211,6 +218,31 @@ describe('ReportsLessonResourceLearnerListPage', () => {
     expect(wrapper.vm.$route.query.groups).toBeUndefined();
   });
 
+  describe('for a lesson without assignments', () => {
+    let lessonMap;
+
+    beforeEach(() => {
+      lessonMap = {
+        [LESSON_ID]: {
+          groups: [],
+          assignments: [],
+        },
+      };
+    });
+
+    describe('when displaying all learners', () => {
+      beforeEach(() => {
+        wrapper = initWrapper(lessonMap);
+      });
+
+      it('renders no class learners', () => {
+        expect(wrapper.html()).not.toContain(LEARNER_1.name);
+        expect(wrapper.html()).not.toContain(LEARNER_2.name);
+        expect(wrapper.html()).not.toContain(LEARNER_3.name);
+      });
+    });
+  });
+
   describe('for an entire class lesson', () => {
     let lessonMap;
 
@@ -218,6 +250,7 @@ describe('ReportsLessonResourceLearnerListPage', () => {
       lessonMap = {
         [LESSON_ID]: {
           groups: [],
+          assignments: [CLASSROOM],
         },
       };
     });
@@ -286,6 +319,7 @@ describe('ReportsLessonResourceLearnerListPage', () => {
       lessonMap = {
         [LESSON_ID]: {
           groups: [GROUP_2.id, GROUP_3.id],
+          assignments: [GROUP_2.id, GROUP_3.id],
         },
       };
     });
diff --git a/kolibri/plugins/coach/assets/test/views/reports/ReportsResourceLearners.spec.js b/kolibri/plugins/coach/assets/test/views/reports/ReportsResourceLearners.spec.js
--- a/kolibri/plugins/coach/assets/test/views/reports/ReportsResourceLearners.spec.js
+++ b/kolibri/plugins/coach/assets/test/views/reports/ReportsResourceLearners.spec.js
@@ -9,6 +9,7 @@ const entries = [
     name: 'learner1',
     username: 'learner1',
     groups: [{ id: 'dc2', name: 'group1' }, { id: '23s', name: 'group2' }],
+    assignments: [{ id: 'dc2', name: 'group1' }, { id: '23s', name: 'group2' }],
     statusObj: {
       learner_id: 'd4b',
       content_id: 'a97',
@@ -22,6 +23,7 @@ const entries = [
     name: 'learner2',
     username: 'learner2',
     groups: [{ id: '23s', name: 'group2' }],
+    assignments: [{ id: '23s', name: 'group2' }],
     statusObj: {
       learner_id: 'a5d',
       content_id: 'a97',
diff --git a/kolibri/plugins/coach/assets/test/views/reports/__snapshots__/ReportsLessonExerciseLearnerListPage.spec.js.snap b/kolibri/plugins/coach/assets/test/views/reports/__snapshots__/ReportsLessonExerciseLearnerListPage.spec.js.snap
--- a/kolibri/plugins/coach/assets/test/views/reports/__snapshots__/ReportsLessonExerciseLearnerListPage.spec.js.snap
+++ b/kolibri/plugins/coach/assets/test/views/reports/__snapshots__/ReportsLessonExerciseLearnerListPage.spec.js.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipents only when displaying all learners renders a correct summary tally 1`] = `
+exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipients only when displaying all learners renders a correct summary tally 1`] = `
 <div class="multi-line" data-test="summary-tally">
   <div debug="everyone finished" class="item"><span class="labeled-icon-wrapper" nowrap="nowrap"><div class="icon"><mat-svg name="stars" category="action" style="fill: #ffc107;"></mat-svg></div> <div dir="auto" class="label">
     Completed by 1 of 1
@@ -10,7 +10,7 @@ exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some gr
 </div>
 `;
 
-exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipents only when displaying learners by groups renders a correct group tally 1`] = `
+exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipients only when displaying learners by groups renders a correct group tally 1`] = `
 <div class="single-line">
   <div debug="ratio; has some completed" class="item"><span class="labeled-icon-wrapper" nowrap="nowrap"><div class="icon"><mat-svg name="stars" category="action" style="fill: #ffc107;"></mat-svg></div> <div dir="auto" class="label">
     1
@@ -35,7 +35,7 @@ exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some gr
 </div>
 `;
 
-exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipents only when displaying learners by groups renders a correct group tally 2`] = `
+exports[`ReportsLessonExerciseLearnerListPage for a lesson restricted to some groups recipients only when displaying learners by groups renders a correct group tally 2`] = `
 <div class="single-line">
   <div debug="no learners" class="item"><span class="labeled-icon-wrapper" nowrap="nowrap"><div class="icon"><mat-svg name="brightness_1" category="image" style="fill: #e0e0e0;"></mat-svg></div> <div dir="auto" class="label">
     0 of 0