Repository: learningequality/kolibri

Issue:
Create Account - Existing username error isn't displayed as expected
<!--
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
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->

Scenario: Username is already taken - I can fill the same name of already existing username (juan) (Step 1) and I don't see error message, and I can continue to the next screen with Gender and Birth Year (Step 2) and from there if I click Continue, nothing happens and if I click the browser Back button, just then the error message for already existing username is seen.

### Expected behavior
<!--
Description of what behavior was expected but did not occur
-->

The error message for already existing username is seen on Step 1, after the username field has the same input as an already existing user. 

### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->

On Step 2 screen it is not clear for the user what exactly is happening. 

### 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
-->

Preconditions: There must be an already existing account.
1. Click Create an account button on the login page
2. On Step 1 page fill the Full name, Username and password field as for Username field use the same input as the one with the already existing account. 
3. Click Continue button
4. On Step 2 page select Gender and/or Birth year
5. Click Continue button

### Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->

Kolibri 0.14.3
OS: Raspbian GNU, Linux 10
Raspberry Pi 4
Chrome (Last version) + FF (Last version)



Discussion:
@metodimilevqa , is the `juan` username the same one you made during the Setup Wizard? It's a known issue that if you try to create a new user with the superuser's name, you will not see an error message inside the form.

However, Kolibri _should_ show some kind of full-page error message, instead of nothing happening after you click Continue.
> @metodimilevqa , is the `juan` username the same one you made during the Setup Wizard? It's a known issue that if you try to create a new user with the superuser's name, you will not see an error message inside the form.
> 
> However, Kolibri _should_ show some kind of full-page error message, instead of nothing happening after you click Continue.

No, "juan" isn't the username I used during setup. For the setup I used metodimilev :)
Yeah, I just reproduced this.  What should be happening is that after creating the account fails, the user gets taken back to the "Step 1" form so they can see this error under the username field:

![image](https://user-images.githubusercontent.com/10248067/97037483-2f32e980-151e-11eb-8f13-3cb56e18ad8a.png)

@jonboiser There is already a check for a duplicate username in the front end:

https://github.com/learningequality/kolibri/blob/30885472fc35d4306791eb2ed896a08b7de73168/kolibri/plugins/user/assets/src/views/SignUpPage.vue#L200

But the back end filters are very strict and they do not return anything.

https://github.com/learningequality/kolibri/blob/006b5a8e29942dbedced29e1be0b9f89b7326a60/kolibri/core/auth/api.py#L260-L264

Is this because you do not want the user details to be exposed and the queryset isn't always `FacilityUser.objects.all()`? In that case, the viewset could return a status code of 200 in case the user name exists or 204 in case it wasn't found and can be used. And in the special case for app context, it would return the list as it does now.
Ah, maybe it's the `dataset__learner_can_login_with_no_password=True` filtering that is causing so many false negatives. 

It's possible this form wasn't tested with a facility where that setting was turned on and we had just assumed `FacilityUsernameViewset` _did_ return a query akin to `FacilityUser.objects.all()` (although I did note in a comment above `checkForDuplicateUsername ` that the superuser was not in there, which makes sense with that second sets of filters).

I think to implement a fix to this issue, we _would_ need to make a separate endpoint with your suggested design just to test for duplicate usernames and not expose too much info. So something like this

`GET /api/usernameexists/jonboiser` -> returns 200 or 204 with no payload
I think we should implement the separate endpoint that @jonboiser suggests or we should just abandon the pre-emptive duplicate username flagging, and just set this error when we attempt to create the user, and the backend returns with the error.
> and just set this error when we attempt to create the user, and the backend returns with the error.

This is what I had in mind as a short-term fix to this issue, where we handle the backend error by going to the Step 1 form. I think the UX is not great because you will go 

1. Name, username, password
2. Demographic data
3. Back to Step 1 (instead of seeing an inline error before going to step 2).

But this might be enough of a rare case that this is okay for now.
Another possibility is that we could create the user, and then do a PATCH with the demographic data afterwards.
> Another possibility is that we could create the user, and then do a PATCH with the demographic data afterwards.

I don't think this is an option because you are allowed to go back and forth from the username/password section and the demographic section. Actually creating the user after Step 1 would change this to a one-way process.

I feel that the adding a new "check if username exists" endpoint might be the best solution and would be useful in the Facility > Users, and Edit Profile page as well.
I think the main issue is probably more the expectation of being able to cancel at any stage in the process without having created the account, so yes, having the endpoint for checking does seem simplest, and less of a security risk than returning all user names from the user name endpoint.
So I think this is the approach we should take to update this form:

1. Add a very simple endpoint that only accepts GET requests and returns a 200/204 (or 404 to take advantage of it being interpreted as an error by convention in axios
1. Update the `checkForDuplicateUsername` method to use this endpoint. If it returns an OK, continue to the next page, otherwise stay put and show the duplicate-username error.

We also need to handle the edge case where a user might be added after the the initial username check, and before the user clicks "Finish" on the demographic info form. In this case, we would need to take the user back to Step 1 and show the duplicate-username error
Currently the issue is also experienced with Kolibri 0.14.6rc1, Windows 7 vm, IE 11.0.9600.

I would like to contribute pls review pr and suggest changes or improvements. 
@jonboiser If this issue is still open, I would like to work on it. Having a separate endpoint to check for duplicate username can solve #7602 too.
Hi @kmrinal19 , yes this issue is still open. Before you submit a PR, could you check in and share the API design you have in mind? I think the basic strategy if have here 

> * Add a very simple endpoint that only accepts GET requests and returns a 200/204 (or 404 to take advantage of it being interpreted as an error by convention in axios
> * Update the `checkForDuplicateUsername` method to use this endpoint. If it returns an OK, continue to the next page, otherwise stay put and show the duplicate-username error.

Should work, but we haven't worked out the details yet (e.g. the request/response payload, error handling, etc.). So some details on how the new "duplicate username" endpoint works would be helpful.
> Hi @kmrinal19 , yes this issue is still open. Before you submit a PR, could you check in and share the API design you have in mind? I think the basic strategy if have here
> 
> > * Add a very simple endpoint that only accepts GET requests and returns a 200/204 (or 404 to take advantage of it being interpreted as an error by convention in axios
> > * Update the `checkForDuplicateUsername` method to use this endpoint. If it returns an OK, continue to the next page, otherwise stay put and show the duplicate-username error.
> 
> Should work, but we haven't worked out the details yet (e.g. the request/response payload, error handling, etc.). So some details on how the new "duplicate username" endpoint works would be helpful.

@jonboiser As you suggested earlier, we can have an endpoint like `GET /api/usernameexists/{username}` which returns a `200` response with a payload like `{ username_exists : false}`. If `username_exists` is false, we can continue to the next page, or if `username_exists` is false, we can show the duplicate-username error. Having an endpoint like this will also help solve #7602, where we have to ensure that the username exists in the sign-in flow.
Sounds like a good plan! I think this new endpoint could be placed in the the `core` app in this module:

https://github.com/learningequality/kolibri/blob/b49b908d4b22de85234c4e67114626bd4947d6d1/kolibri/core/auth/api_urls.py#L18-L25

When doing this `username_exists` check, make sure to keep the `facility` in mind. A Kolibri device can have multiple facilities in it, and it is okay for different facilities to have `usernames` in common.

So if I try to create an account in Facility A with username `user_1`, I should not get blocked if Facility B already has that username reserved.
@jonboiser I was wondering if we can have an endpoint like `GET api/auth/usernameexists`  with `username` and `facility`  as GET parameters in place of `GET /api/usernameexists/{username}`. 
Something like this?
```
GET /api/usernameexists?username=...&facility=...
```

I think that would be okay. I think if you implement this endpoint, I would want to query it from the client using a `HEAD` request instead of `GET`, so make sure that works too. 
> Something like this?
> 
> ```
> GET /api/usernameexists?username=...&facility=...
> ```
> 
> I think that would be okay. I think if you implement this endpoint, I would want to query it from the client using a `HEAD` request instead of `GET`, so make sure that works too.

@jonboiser The endpoint mentioned above will return a response with payload like `{ username_exists : false}`, so why do we need to query the endpoint using a `HEAD` request instead of `GET`, as `HEAD` request does not allow payload response?
Ah okay, that makes sense since the "username doesn't exist" case is actually the "good"/non-error case.

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
@@ -259,6 +259,32 @@ def perform_create(self, serializer):
         self.set_password_if_needed(instance, serializer)
 
 
+class ExistingUsernameView(views.APIView):
+    def get(self, request):
+        username = request.GET.get("username")
+        facility_id = request.GET.get("facility")
+
+        if not username or not facility_id:
+            return Response(
+                "Must specify username, and facility",
+                status=status.HTTP_400_BAD_REQUEST,
+            )
+
+        try:
+            Facility.objects.get(id=facility_id)
+        except (ValueError, ObjectDoesNotExist):
+            return Response(
+                "Facility not found",
+                status=status.HTTP_404_NOT_FOUND,
+            )
+
+        try:
+            FacilityUser.objects.get(username__iexact=username, facility=facility_id)
+            return Response({"username_exists": True}, status=status.HTTP_200_OK)
+        except ObjectDoesNotExist:
+            return Response({"username_exists": False}, status=status.HTTP_200_OK)
+
+
 class FacilityUsernameViewSet(ValuesViewset):
     filter_backends = (DjangoFilterBackend, filters.SearchFilter)
     filter_fields = ("facility",)
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
@@ -2,6 +2,7 @@
 from rest_framework import routers
 
 from .api import ClassroomViewSet
+from .api import ExistingUsernameView
 from .api import FacilityDatasetViewSet
 from .api import FacilityUsernameViewSet
 from .api import FacilityUserViewSet
@@ -42,6 +43,11 @@
             r"^setnonspecifiedpassword$",
             SetNonSpecifiedPasswordView.as_view(),
             name="setnonspecifiedpassword",
-        )
+        ),
+        url(
+            r"^usernameexists$",
+            ExistingUsernameView.as_view(),
+            name="usernameexists",
+        ),
     ]
 )


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
@@ -1159,3 +1159,40 @@ def test_create_class_membership_group_membership_different_class(self):
             url, {"user": self.user.id, "collection": self.classroom2.id}, format="json"
         )
         self.assertEqual(response.status_code, 201)
+
+
+class DuplicateUsernameTestCase(APITestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.facility = FacilityFactory.create()
+        cls.user = FacilityUserFactory.create(facility=cls.facility, username="user")
+        cls.url = reverse("kolibri:core:usernameexists")
+        provision_device()
+
+    def test_check_duplicate_username_with_unique_username(self):
+        response = self.client.get(
+            self.url,
+            {"username": "new_user", "facility": self.facility.id},
+            format="json",
+        )
+        expected = {"username_exists": False}
+        self.assertDictEqual(response.data, expected)
+
+    def test_check_duplicate_username_with_existing_username(self):
+        response = self.client.get(
+            self.url,
+            {"username": self.user.username, "facility": self.facility.id},
+            format="json",
+        )
+        expected = {"username_exists": True}
+        self.assertDictEqual(response.data, expected)
+
+    def test_check_duplicate_username_with_existing_username_other_facility(self):
+        other_facility = FacilityFactory.create()
+        response = self.client.get(
+            self.url,
+            {"username": self.user.username, "facility": other_facility.id},
+            format="json",
+        )
+        expected = {"username_exists": False}
+        self.assertDictEqual(response.data, expected)