Repository: learningequality/kolibri

Issue:
Make learner polling indicator conditional for only SoUD
For the new polling indicator on the dropdown menu, this should be updated to appear and poll only when the user is a learner who is logged in on a subset of users device. 

<img width="270" alt="Screen Shot 2021-08-05 at 10 58 50 AM" src="https://user-images.githubusercontent.com/17235236/128372343-da85c951-2258-4d5e-ade3-a15b753e6806.png">



Discussion:

Resolving patch:
diff --git a/kolibri/core/kolibri_plugin.py b/kolibri/core/kolibri_plugin.py
--- a/kolibri/core/kolibri_plugin.py
+++ b/kolibri/core/kolibri_plugin.py
@@ -22,6 +22,7 @@
 from kolibri.core.content.utils.paths import get_zip_content_config
 from kolibri.core.device.models import ContentCacheKey
 from kolibri.core.device.utils import allow_other_browsers_to_connect
+from kolibri.core.device.utils import get_device_setting
 from kolibri.core.hooks import NavigationHook
 from kolibri.core.oidc_provider_hook import OIDCProviderHook
 from kolibri.core.theme_hook import ThemeHook
@@ -127,6 +128,7 @@ def plugin_data(self):
             "languageGlobals": self.language_globals(),
             "oidcProviderEnabled": OIDCProviderHook.is_enabled(),
             "kolibriTheme": ThemeHook.get_theme(),
+            "isSubsetOfUsersDevice": get_device_setting("subset_of_users_device"),
         }
 
     def language_globals(self):
diff --git a/kolibri/plugins/coach/kolibri_plugin.py b/kolibri/plugins/coach/kolibri_plugin.py
--- a/kolibri/plugins/coach/kolibri_plugin.py
+++ b/kolibri/plugins/coach/kolibri_plugin.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 from kolibri.core.auth.constants.user_kinds import COACH
+from kolibri.core.device.utils import get_device_setting
 from kolibri.core.hooks import NavigationHook
 from kolibri.core.hooks import RoleBasedRedirectHook
 from kolibri.core.webpack import hooks as webpack_hooks
@@ -12,7 +13,14 @@
 
 class Coach(KolibriPluginBase):
     untranslated_view_urls = "api_urls"
-    translated_view_urls = "urls"
+
+    @property
+    def translated_view_urls(self):
+        # On an SoUD this plugin should be disabled. In lieu of properly
+        # disabling the plugin, we will just not register any urls for now
+        if not get_device_setting("subset_of_users_device", False):
+            return "urls"
+        return None
 
 
 @register_hook
diff --git a/kolibri/plugins/facility/kolibri_plugin.py b/kolibri/plugins/facility/kolibri_plugin.py
--- a/kolibri/plugins/facility/kolibri_plugin.py
+++ b/kolibri/plugins/facility/kolibri_plugin.py
@@ -3,6 +3,7 @@
 from __future__ import unicode_literals
 
 from kolibri.core.auth.constants.user_kinds import ADMIN
+from kolibri.core.device.utils import get_device_setting
 from kolibri.core.hooks import NavigationHook
 from kolibri.core.hooks import RoleBasedRedirectHook
 from kolibri.core.webpack.hooks import WebpackBundleHook
@@ -11,9 +12,16 @@
 
 
 class FacilityManagementPlugin(KolibriPluginBase):
-    translated_view_urls = "urls"
     untranslated_view_urls = "api_urls"
 
+    @property
+    def translated_view_urls(self):
+        # On an SoUD this plugin should be disabled. In lieu of properly
+        # disabling the plugin, we will just not register any urls for now
+        if not get_device_setting("subset_of_users_device", False):
+            return "urls"
+        return None
+
 
 @register_hook
 class FacilityManagementAsset(WebpackBundleHook):


Test patch:
diff --git a/kolibri/core/assets/test/views/app-bar.spec.js b/kolibri/core/assets/test/views/app-bar.spec.js
--- a/kolibri/core/assets/test/views/app-bar.spec.js
+++ b/kolibri/core/assets/test/views/app-bar.spec.js
@@ -6,10 +6,14 @@ import CoreMenuOption from 'kolibri.coreVue.components.CoreMenuOption';
 import AppBar from '../../src/views/AppBar';
 import logoutSideNavEntry from '../../src/views/LogoutSideNavEntry';
 import { coreStoreFactory as makeStore } from '../../src/state/store';
+import LearnOnlyDeviceNotice from '../../src/views/LearnOnlyDeviceNotice';
+import SyncStatusDisplay from '../../src/views/SyncStatusDisplay';
 
 jest.mock('kolibri.urls');
 
 function createWrapper({ navShown = true, height = 20, title = 'test' } = {}, data) {
+  let store = makeStore();
+
   return mount(AppBar, {
     propsData: {
       navShown,
@@ -19,7 +23,7 @@ function createWrapper({ navShown = true, height = 20, title = 'test' } = {}, da
     data() {
       return { ...data };
     },
-    store: makeStore(),
+    store,
   });
 }
 
@@ -66,11 +70,68 @@ describe('app bar component', () => {
     });
     it('should show logout if no components are added and user is logged in', async () => {
       expect(navComponents).toHaveLength(0);
-      const wrapper = createWrapper();
+      const wrapper = createWrapper(undefined, {});
       setUserKind(wrapper.vm.$store, UserKinds.LEARNER);
       await wrapper.vm.$nextTick();
       expect(wrapper.findComponent(logoutSideNavEntry).element).toBeTruthy();
     });
+
+    describe('SoUD sync status and learn-only device indicators', () => {
+      describe('on an SoUD', () => {
+        let wrapper;
+        beforeAll(() => {
+          wrapper = createWrapper(undefined, { isSubsetOfUsersDevice: true });
+        });
+        describe('showing the SyncStatusDisplay', () => {
+          it('shows the SyncStatusDisplay to Learners', async () => {
+            setUserKind(wrapper.vm.$store, UserKinds.LEARNER);
+            await wrapper.vm.$nextTick();
+            expect(wrapper.findComponent(SyncStatusDisplay).exists()).toBe(true);
+          });
+
+          it.each([UserKinds.COACH, UserKinds.ADMIN, UserKinds.ANONYMOUS])(
+            'does not show the SyncStatusDisplay to %s',
+            async kind => {
+              setUserKind(wrapper.vm.$store, kind);
+              await wrapper.vm.$nextTick();
+              expect(wrapper.findComponent(SyncStatusDisplay).exists()).toBe(false);
+            }
+          );
+        });
+
+        describe('showing the LearnOnlyDeviceNotice', () => {
+          it.each([UserKinds.LEARNER, UserKinds.ANONYMOUS])(
+            'hides the notice from %s',
+            async kind => {
+              setUserKind(wrapper.vm.$store, kind);
+              await wrapper.vm.$nextTick();
+              expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(false);
+            }
+          );
+
+          it.each([UserKinds.ADMIN, UserKinds.COACH])('shows the notice to %s', async kind => {
+            setUserKind(wrapper.vm.$store, kind);
+            await wrapper.vm.$nextTick();
+            expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(true);
+          });
+        });
+      });
+      describe('not on a SoUD', () => {
+        let wrapper;
+        beforeEach(() => {
+          wrapper = createWrapper(undefined, { isSubsetOfUsersDevice: false });
+        });
+
+        it('no indicator is shown', () => {
+          expect(wrapper.find('[data-test="syncStatusInDropdown"]').exists()).toBe(false);
+        });
+
+        it('no notice is shown', () => {
+          expect(wrapper.find('[data-test="learnOnlyNotice"]').exists()).toBe(false);
+        });
+      });
+    });
+
     describe('only non-account components are added', () => {
       afterEach(() => {
         navComponents.pop();
diff --git a/kolibri/core/assets/test/views/side-nav.spec.js b/kolibri/core/assets/test/views/side-nav.spec.js
--- a/kolibri/core/assets/test/views/side-nav.spec.js
+++ b/kolibri/core/assets/test/views/side-nav.spec.js
@@ -4,17 +4,21 @@ import navComponents from 'kolibri.utils.navComponents';
 import { UserKinds, NavComponentSections } from 'kolibri.coreVue.vuex.constants';
 import SideNav from '../../src/views/SideNav';
 import logoutSideNavEntry from '../../src/views/LogoutSideNavEntry';
+import LearnOnlyDeviceNotice from '../../src/views/LearnOnlyDeviceNotice';
 import { coreStoreFactory as makeStore } from '../../src/state/store';
 
 jest.mock('kolibri.urls');
 
-function createWrapper({ navShown = true, headerHeight = 20, width = 100 } = {}) {
+function createWrapper({ navShown = true, headerHeight = 20, width = 100 } = {}, data = {}) {
   return mount(SideNav, {
     propsData: {
       navShown,
       headerHeight,
       width,
     },
+    data() {
+      return { ...data };
+    },
     store: makeStore(),
   });
 }
@@ -184,4 +188,40 @@ describe('side nav component', () => {
       expect(wrapper.vm.menuOptions[0]).toBe(component2);
     });
   });
+
+  describe('when on an SoUD or NOT', () => {
+    describe('on an SoUD', () => {
+      /* Note that Facilty & Coach plugins are hackily disabled in their kolibri_plugin
+       * definitions - hence no tests to ensure they're hidden here when on SoUD */
+      it('shows the Learn-only notice to non-Learners', async () => {
+        const wrapper = createWrapper(undefined, { isSubsetOfUsersDevice: true });
+        setUserKind(wrapper.vm.$store, UserKinds.COACH);
+        await wrapper.vm.$nextTick();
+        expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(true);
+        setUserKind(wrapper.vm.$store, UserKinds.ADMIN);
+        await wrapper.vm.$nextTick();
+        expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(true);
+      });
+
+      it('does not show learn-only notice to Learners or Guests', async () => {
+        const wrapper = createWrapper(undefined, { isSubsetOfUsersDevice: true });
+        setUserKind(wrapper.vm.$store, UserKinds.LEARNER);
+        await wrapper.vm.$nextTick();
+        expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(false);
+        setUserKind(wrapper.vm.$store, UserKinds.ANONYMOUS);
+        await wrapper.vm.$nextTick();
+        expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(false);
+      });
+    });
+    describe('NOT on a SoUD', () => {
+      let wrapper;
+      beforeEach(async () => {
+        wrapper = createWrapper(undefined, { isSubsetOfUsersDevice: false });
+      });
+
+      it('shows no notice', () => {
+        expect(wrapper.findComponent(LearnOnlyDeviceNotice).exists()).toBe(false);
+      });
+    });
+  });
 });
diff --git a/kolibri/core/test/test_key_urls.py b/kolibri/core/test/test_key_urls.py
--- a/kolibri/core/test/test_key_urls.py
+++ b/kolibri/core/test/test_key_urls.py
@@ -17,7 +17,6 @@
 from kolibri.core.auth.test.test_api import FacilityFactory
 from kolibri.core.auth.test.test_api import FacilityUserFactory
 from kolibri.core.device.translation import get_settings_language
-from kolibri.deployment.default.urls import urlpatterns
 
 
 class BeforeDeviceProvisionTests(APITestCase):
@@ -160,7 +159,21 @@ def check_urls(urlpatterns, prefix=""):
         ), patch("kolibri.core.tasks.api.queue"), patch(
             "kolibri.core.webpack.hooks.WebpackBundleHook.get_by_unique_id"
         ):
-            check_urls(urlpatterns)
+            # A slight hack to accommodate the SoUD tests ensuring that Coach and Facility plugins are not
+            # available to the frontend. If for any reason you decide to use get_device_setting in the
+            # kolibri_plugin of either Coach or Facility.
+            # This ensures that the subsequent import of urlpatterns doesn't try to touch the database and
+            # we touch the database in these two places.
+            with patch(
+                "kolibri.plugins.coach.kolibri_plugin.get_device_setting",
+                return_value=False,
+            ), patch(
+                "kolibri.plugins.facility.kolibri_plugin.get_device_setting",
+                return_value=False,
+            ):
+                from kolibri.deployment.default.urls import urlpatterns
+
+                check_urls(urlpatterns)
 
     def test_anonymous_responses(self):
         self.check_responses()
diff --git a/kolibri/plugins/coach/test/test_subset_of_users_device.py b/kolibri/plugins/coach/test/test_subset_of_users_device.py
new file mode 100644
--- /dev/null
+++ b/kolibri/plugins/coach/test/test_subset_of_users_device.py
@@ -0,0 +1,27 @@
+from __future__ import absolute_import
+from __future__ import print_function
+from __future__ import unicode_literals
+
+from mock import patch
+from rest_framework.test import APITestCase
+
+from kolibri.core.auth.test.helpers import provision_device
+from kolibri.plugins.coach.kolibri_plugin import Coach
+
+
+class CoachOnSoUDTestCase(APITestCase):
+    def setUp(self):
+        provision_device()
+
+    def test_urls_on_not_soud(self):
+        with patch(
+            "kolibri.plugins.coach.kolibri_plugin.get_device_setting",
+            return_value=False,
+        ):
+            self.assertIsNotNone(Coach().translated_view_urls)
+
+    def test_no_urls_on_soud(self):
+        with patch(
+            "kolibri.plugins.coach.kolibri_plugin.get_device_setting", return_value=True
+        ):
+            self.assertIsNone(Coach().translated_view_urls)
diff --git a/kolibri/plugins/facility/test/__init__.py b/kolibri/plugins/facility/test/__init__.py
new file mode 100644
diff --git a/kolibri/plugins/facility/test/test_subset_of_users_device.py b/kolibri/plugins/facility/test/test_subset_of_users_device.py
new file mode 100644
--- /dev/null
+++ b/kolibri/plugins/facility/test/test_subset_of_users_device.py
@@ -0,0 +1,28 @@
+from __future__ import absolute_import
+from __future__ import print_function
+from __future__ import unicode_literals
+
+from mock import patch
+from rest_framework.test import APITestCase
+
+from kolibri.core.auth.test.helpers import provision_device
+from kolibri.plugins.facility.kolibri_plugin import FacilityManagementPlugin
+
+
+class FacilityOnSoUDTestCase(APITestCase):
+    def setUp(self):
+        provision_device()
+
+    def test_urls_on_not_soud(self):
+        with patch(
+            "kolibri.plugins.facility.kolibri_plugin.get_device_setting",
+            return_value=False,
+        ):
+            self.assertIsNotNone(FacilityManagementPlugin().translated_view_urls)
+
+    def test_no_urls_on_soud(self):
+        with patch(
+            "kolibri.plugins.facility.kolibri_plugin.get_device_setting",
+            return_value=True,
+        ):
+            self.assertIsNone(FacilityManagementPlugin().translated_view_urls)