Repository: learningequality/kolibri

Issue:
SignInPage does not implement the "optional learner password" facility setting
<!--
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 1: 

I try to login as a learner `l`, but am brought to this second "Create a password" page. (Also see other issues pointed out in screenshot)

![image](https://user-images.githubusercontent.com/10248067/85910455-c176eb00-b7d3-11ea-94b4-cf1386b5cd0d.png)

Screenshot 2:

I try to login as superuser `jb`. I get stuck at the sign-in page

![image](https://user-images.githubusercontent.com/10248067/85910489-00a53c00-b7d4-11ea-88b1-72ac90f11e9e.png)

### Expected behavior



### 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/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
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context
0.14 beta 6


Discussion:
I think this is related to #7115 as I could reproduce these same behaviour when using app mode, @jonboiser had you activated app mode when this happened to you?
I ran `kolibri plugin list` just now and the `app` plugin is disabled.

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
@@ -15,13 +15,13 @@
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
-from django.db.models import Exists
 from django.db.models import OuterRef
 from django.db.models import Q
 from django.db.models import Subquery
 from django.db.models import TextField
 from django.db.models.functions import Cast
 from django.db.models.query import F
+from django.http import Http404
 from django.utils.decorators import method_decorator
 from django.utils.timezone import now
 from django.views.decorators.csrf import ensure_csrf_cookie
@@ -33,8 +33,8 @@
 from rest_framework import filters
 from rest_framework import permissions
 from rest_framework import status
+from rest_framework import views
 from rest_framework import viewsets
-from rest_framework.decorators import list_route
 from rest_framework.response import Response
 
 from .constants import collection_kinds
@@ -60,7 +60,6 @@
 from .serializers import RoleSerializer
 from kolibri.core import error_constants
 from kolibri.core.api import ValuesViewset
-from kolibri.core.decorators import MissingRequiredParamsException
 from kolibri.core.device.utils import allow_guest_access
 from kolibri.core.device.utils import allow_other_browsers_to_connect
 from kolibri.core.device.utils import valid_app_key_on_request
@@ -207,28 +206,6 @@ class FacilityUserViewSet(ValuesViewset):
         "is_superuser": lambda x: bool(x.pop("devicepermissions__is_superuser"))
     }
 
-    @list_route(methods=["get"])
-    def users_for_facilities(self, request):
-        if "member_of" not in request.GET:
-            raise MissingRequiredParamsException(
-                "member_of is required for this endpoint"
-            )
-
-        facility_ids = request.GET["member_of"].split(",")
-
-        users = (
-            FacilityUser.objects.filter(facility__in=facility_ids)
-            .annotate(is_learner=Exists(Role.objects.filter(user=OuterRef("id"))))
-            .values("id", "username", "facility_id", "is_learner", "password")
-        )
-
-        def sanitize_users(user):
-            password = user.pop("password")
-            user["needs_password"] = password == "NOT_SPECIFIED" or not password
-            return user
-
-        return Response(list(map(sanitize_users, users)))
-
     def consolidate(self, items, queryset):
         output = []
         items = sorted(items, key=lambda x: x["id"])
@@ -278,6 +255,10 @@ class FacilityUsernameViewSet(ValuesViewset):
     values = ("username",)
 
     def get_queryset(self):
+        if valid_app_key_on_request(self.request):
+            # Special case for app context to return usernames for
+            # the list display
+            return FacilityUser.objects.all()
         return FacilityUser.objects.filter(
             dataset__learner_can_login_with_no_password=True, roles=None
         ).filter(
@@ -360,18 +341,9 @@ def _map_dataset(facility, dataset_keys=dataset_keys):
         return dataset
 
     field_map = {
-        "default": lambda x: Facility.get_default_facility().id == x["id"],
         "dataset": _map_dataset,
     }
 
-    def get_queryset(self, prefetch=True):
-        queryset = Facility.objects.all()
-        if prefetch:
-            # This is a default field on the serializer, so do a select_related
-            # to prevent n queries when n facilities are queried
-            return queryset.select_related("dataset")
-        return queryset
-
     def annotate_queryset(self, queryset):
         return (
             queryset.annotate(
@@ -575,6 +547,34 @@ def create(self, request):
             return Response(serialized_user.data, status=status.HTTP_201_CREATED)
 
 
+class SetNonSpecifiedPasswordView(views.APIView):
+    def post(self, request):
+        username = request.data.get("username", "")
+        password = request.data.get("password", "")
+        facility_id = request.data.get("facility", None)
+
+        if not username or not password or not facility_id:
+            return Response(
+                "Must specify username, password, and facility",
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        error_message = "Suitable user does not exist"
+
+        try:
+            user = FacilityUser.objects.get(username=username, facility=facility_id)
+        except ObjectDoesNotExist:
+            raise Http404(error_message)
+
+        if user.password != "NOT_SPECIFIED":
+            raise Http404(error_message)
+
+        user.set_password(password)
+        user.save()
+
+        return Response()
+
+
 @method_decorator(ensure_csrf_cookie, name="dispatch")
 class SessionViewSet(viewsets.ViewSet):
     def create(self, request):
@@ -614,8 +614,18 @@ def create(self, request):
             # Here - we have a Learner whose password is "NOT_SPECIFIED" because they were created
             # while the "Require learners to log in with password" setting was disabled - but now
             # it is enabled again.
-            login(request, unauthenticated_user)
-            return self.get_session_response(request)
+            return Response(
+                [
+                    {
+                        "id": error_constants.PASSWORD_NOT_SPECIFIED,
+                        "metadata": {
+                            "field": "password",
+                            "message": "Username is valid, but password needs to be set before login.",
+                        },
+                    }
+                ],
+                status=status.HTTP_400_BAD_REQUEST,
+            )
         elif (
             not password
             and FacilityUser.objects.filter(
diff --git a/kolibri/core/auth/api_urls.py b/kolibri/core/auth/api_urls.py
--- a/kolibri/core/auth/api_urls.py
+++ b/kolibri/core/auth/api_urls.py
@@ -1,3 +1,4 @@
+from django.conf.urls import url
 from rest_framework import routers
 
 from .api import AdHocGroupViewSet
@@ -10,6 +11,7 @@
 from .api import MembershipViewSet
 from .api import RoleViewSet
 from .api import SessionViewSet
+from .api import SetNonSpecifiedPasswordView
 from .api import SignUpViewSet
 from kolibri.core.api import KolibriDataPortalViewSet
 from kolibri.core.routers import BulkDeleteRouter
@@ -36,4 +38,14 @@
 bulk_delete_router.register(r"membership", MembershipViewSet, base_name="membership")
 bulk_delete_router.register(r"role", RoleViewSet, base_name="role")
 
-urlpatterns = router.urls + bulk_delete_router.urls
+urlpatterns = (
+    router.urls
+    + bulk_delete_router.urls
+    + [
+        url(
+            r"^setnonspecifiedpassword$",
+            SetNonSpecifiedPasswordView.as_view(),
+            name="setnonspecifiedpassword",
+        )
+    ]
+)
diff --git a/kolibri/core/error_constants.py b/kolibri/core/error_constants.py
--- a/kolibri/core/error_constants.py
+++ b/kolibri/core/error_constants.py
@@ -12,6 +12,7 @@
 INVALID_NETWORK_LOCATION_FORMAT = "INVALID_NETWORK_LOCATION_FORMAT"
 NETWORK_LOCATION_NOT_FOUND = "NETWORK_LOCATION_NOT_FOUND"
 INVALID_KDP_REGISTRATION_TOKEN = "INVALID_KDP_REGISTRATION_TOKEN"
+PASSWORD_NOT_SPECIFIED = "PASSWORD_NOT_SPECIFIED"
 # 401 error constants
 INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
 # 404 error constants


Test patch:
diff --git a/kolibri/core/assets/test/state/store.spec.js b/kolibri/core/assets/test/state/store.spec.js
--- a/kolibri/core/assets/test/state/store.spec.js
+++ b/kolibri/core/assets/test/state/store.spec.js
@@ -81,8 +81,8 @@ describe('Vuex store/actions for core module', () => {
         }),
       });
 
-      await store.dispatch('kolibriLogin', {});
-      expect(store.state.core.loginError).toEqual(constants.LoginErrors.INVALID_CREDENTIALS);
+      const error = await store.dispatch('kolibriLogin', {});
+      expect(error).toEqual(constants.LoginErrors.INVALID_CREDENTIALS);
     });
   });
 });
diff --git a/kolibri/plugins/user/assets/test/views/sign-in-page.spec.js b/kolibri/plugins/user/assets/test/views/sign-in-page.spec.js
--- a/kolibri/plugins/user/assets/test/views/sign-in-page.spec.js
+++ b/kolibri/plugins/user/assets/test/views/sign-in-page.spec.js
@@ -10,6 +10,7 @@ function makeWrapper() {
   store.state.core.facilities.push({
     id: '123',
     name: 'test facility',
+    dataset: {},
   });
   return mount(SignInPage, {
     store,