Repository: learningequality/kolibri

Issue:
The FacilityDataset `preset` field is not used to reset facility defaults on UI
<!--
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

Screenshot from https://kolibri-beta.learningequality.org/en/facility/#/a3f606630690237bbe94fe2a1a850af4/settings

Clicking "Reset to defaults" will always update the checkboxes to the default settings for "nonformal" facilities.

![image](https://user-images.githubusercontent.com/10248067/98396284-5d322680-2012-11eb-8d49-6eae61a00a3a.png)

This will happen even if the `FacilityDataset.preset` field is _not_ `nonformal`. This happens because we have hardcoded the defaults in this JS file:

https://github.com/learningequality/kolibri/blob/release-v0.14.x/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js#L47-L59

### Expected behavior

When a user clicks "reset to defaults", the defaults used are based on the `FacilityDataset.preset` field. And the end result are that the settings match what is this file. For example, if a facility was set up as an informal/personal device, then "Allow learner to edit username" will be checked, and so on, because the default value of `learner_can_edit_username` is `true`.

https://github.com/learningequality/kolibri/blob/release-v0.14.x/kolibri/core/auth/constants/facility_configuration_presets.json

### User-facing consequences

Admins might not realize that a "preset" is actually chosen during the Setup Wizard and that these settings are related to that preset. Correcting the defaults after a reset would require knowing of the existence of the JSON file linked above and manually correct them.

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/logs/kolibri.txt
 * 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

1. Set up a device with an informal/formal facility
1. Change the settings on the settings page
1. Click "reset to defaults"
1. The new settings will not be the default for the facility `preset`, but will be for the nonformal preset.

### Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->




Discussion:
Currently, the way we handle the "reset" logic is by calling this Vuex action from the page

https://github.com/learningequality/kolibri/blob/3a290e36eba43353ad58e57a783e49c21664e15b/kolibri/plugins/facility/assets/src/views/FacilityConfigPage/index.vue#L233-L237

This works by updating the settings on the client using this hardcoded map, then sending that map back to the frontend.

https://github.com/learningequality/kolibri/blob/3a290e36eba43353ad58e57a783e49c21664e15b/kolibri/plugins/facility/assets/src/modules/facilityConfig/actions.js#L38-L49

However, this might be better handled on the backend by either introducing a new ViewSet or extending the current `FacilityDatasetViewset` to handle a "reset" operation, which requires no payload of specific settings, but would instead refer to the `facility_configuration_presets.json` file and update the settings based on that map. Then, the server would return the new map of settings (with the defaults), that the client could use to update itself.

Currently, the `FacilityDatasetViewset` might just allow the basic methods like PATCH, but maybe we can add another method-based endpoint to handle the reset operation

https://github.com/learningequality/kolibri/blob/3a290e36eba43353ad58e57a783e49c21664e15b/kolibri/core/auth/api.py#L136-L166

Resolving patch:
diff --git a/kolibri/core/auth/api.py b/kolibri/core/auth/api.py
--- a/kolibri/core/auth/api.py
+++ b/kolibri/core/auth/api.py
@@ -14,6 +14,7 @@
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import PermissionDenied
 from django.db import transaction
 from django.db.models import Func
 from django.db.models import OuterRef
@@ -32,6 +33,7 @@
 from django_filters.rest_framework import FilterSet
 from django_filters.rest_framework import ModelChoiceFilter
 from morango.models import TransferSession
+from rest_framework import decorators
 from rest_framework import filters
 from rest_framework import permissions
 from rest_framework import status
@@ -166,6 +168,18 @@ def get_queryset(self):
             queryset = queryset.filter(collection__id=facility_id)
         return queryset
 
+    @decorators.action(methods=["post"], detail=True)
+    def resetsettings(self, request, pk):
+        try:
+            dataset = FacilityDataset.objects.get(pk=pk)
+            if not request.user.can_update(dataset):
+                raise PermissionDenied("You cannot reset this facility's settings")
+            dataset.reset_to_default_settings()
+            data = FacilityDatasetSerializer(dataset).data
+            return Response(data)
+        except FacilityDataset.DoesNotExist:
+            raise Http404("Facility does not exist")
+
 
 class FacilityUserFilter(FilterSet):
 
diff --git a/kolibri/core/auth/models.py b/kolibri/core/auth/models.py
--- a/kolibri/core/auth/models.py
+++ b/kolibri/core/auth/models.py
@@ -164,6 +164,15 @@ def get_owned_certificates(self):
             tree_id=self.get_root_certificate().tree_id
         ).exclude(_private_key=None)
 
+    def reset_to_default_settings(self, preset=None):
+        from kolibri.core.auth.constants.facility_presets import mappings
+
+        # use the current preset if it is not passed in
+        dataset_data = mappings[preset or self.preset]
+        for key, value in dataset_data.items():
+            setattr(self, key, value)
+        self.save()
+
 
 class AbstractFacilityDataModel(FacilityDataSyncableModel):
     """
diff --git a/kolibri/core/device/serializers.py b/kolibri/core/device/serializers.py
--- a/kolibri/core/device/serializers.py
+++ b/kolibri/core/device/serializers.py
@@ -4,7 +4,6 @@
 from rest_framework import serializers
 
 from kolibri.core.auth.constants.facility_presets import choices
-from kolibri.core.auth.constants.facility_presets import mappings
 from kolibri.core.auth.models import Facility
 from kolibri.core.auth.models import FacilityUser
 from kolibri.core.auth.serializers import FacilitySerializer
@@ -74,10 +73,8 @@ def create(self, validated_data):
         with transaction.atomic():
             facility = Facility.objects.create(**validated_data.pop("facility"))
             preset = validated_data.pop("preset")
-            dataset_data = mappings[preset]
             facility.dataset.preset = preset
-            for key, value in dataset_data.items():
-                setattr(facility.dataset, key, value)
+            facility.dataset.reset_to_default_settings(preset)
             # overwrite the settings in dataset_data with validated_data.settings
             custom_settings = validated_data.pop("settings")
             for key, value in custom_settings.items():


Test patch:
diff --git a/kolibri/core/auth/test/test_api.py b/kolibri/core/auth/test/test_api.py
--- a/kolibri/core/auth/test/test_api.py
+++ b/kolibri/core/auth/test/test_api.py
@@ -14,6 +14,7 @@
 
 from .. import models
 from ..constants import role_kinds
+from ..constants.facility_presets import mappings
 from .helpers import create_superuser
 from .helpers import DUMMY_PASSWORD
 from .helpers import provision_device
@@ -950,6 +951,42 @@ def test_facility_user_cannot_delete_dataset(self):
         )
         self.assertEqual(response.status_code, 403)
 
+    def test_facility_admin_can_reset_settings(self):
+        facility = FacilityFactory.create()
+        admin = FacilityUserFactory.create(facility=facility)
+        facility.add_admin(admin)
+
+        self.client.login(username=admin.username, password=DUMMY_PASSWORD)
+
+        def set_all_false_and_preset(facility, preset):
+            all_false = {
+                "learner_can_edit_username": False,
+                "learner_can_edit_name": False,
+                "learner_can_edit_password": False,
+                "learner_can_sign_up": False,
+                "learner_can_delete_account": False,
+                "learner_can_login_with_no_password": False,
+                "show_download_button_in_learn": False,
+            }
+            for key, value in all_false.items():
+                setattr(facility.dataset, key, value)
+            facility.dataset.preset = preset
+            facility.dataset.save()
+
+        def post_resetsettings():
+            return self.client.post(
+                reverse(
+                    "kolibri:core:facilitydataset-resetsettings",
+                    kwargs={"pk": facility.dataset_id},
+                ),
+            )
+
+        # test all three presets
+        for setting in ["formal", "nonformal", "informal"]:
+            set_all_false_and_preset(facility, setting)
+            response = post_resetsettings()
+            self.assertDictContainsSubset(mappings[setting], response.data)
+
 
 class MembershipCascadeDeletion(APITestCase):
     @classmethod
diff --git a/kolibri/plugins/facility/assets/test/state/facilityConfig.spec.js b/kolibri/plugins/facility/assets/test/state/facilityConfig.spec.js
--- a/kolibri/plugins/facility/assets/test/state/facilityConfig.spec.js
+++ b/kolibri/plugins/facility/assets/test/state/facilityConfig.spec.js
@@ -1,8 +1,14 @@
 import { FacilityResource, FacilityDatasetResource } from 'kolibri.resources';
+import client from 'kolibri.client';
 import { showFacilityConfigPage } from '../../src/modules/facilityConfig/handlers';
 import { jestMockResource } from 'testUtils'; // eslint-disable-line
 import makeStore from '../makeStore';
 
+jest.mock('kolibri.client', () => jest.fn());
+jest.mock('kolibri.urls', () => ({
+  'kolibri:core:facilitydataset-resetsettings': () => {},
+}));
+
 const FacilityStub = jestMockResource(FacilityResource);
 const DatasetStub = jestMockResource(FacilityDatasetResource);
 
@@ -156,18 +162,18 @@ describe('facility config page actions', () => {
     });
 
     it('resetFacilityConfig action dispatches resets settings and makes a save request', () => {
-      const saveStub = DatasetStub.__getModelSaveReturns('ok default');
+      const expected = {
+        learner_can_edit_username: true,
+        learner_can_edit_name: false,
+        learner_can_edit_password: true,
+        learner_can_sign_up: false,
+        learner_can_delete_account: true,
+        learner_can_login_with_no_password: false,
+        show_download_button_in_learn: true,
+      };
+      client.mockResolvedValue({ data: expected });
       return store.dispatch('facilityConfig/resetFacilityConfig').then(() => {
-        expect(saveStub).toHaveBeenCalled();
-        expect(store.state.facilityConfig.settings).toEqual({
-          learner_can_edit_username: true,
-          learner_can_edit_name: true,
-          learner_can_edit_password: true,
-          learner_can_sign_up: true,
-          learner_can_delete_account: true,
-          learner_can_login_with_no_password: false,
-          show_download_button_in_learn: false,
-        });
+        expect(store.state.facilityConfig.settings).toEqual(expected);
       });
     });
   });