Repository: learningequality/kolibri

Issue:
'importusers' and “createsuperuser” management commands should not allow empty 'full name' field
<!--
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

The `kolibri manage importusers` command allows empty 'full name' fields when importing from CSV  files and defaults them to the empty string. This allows Facility Users to be made who have an empty full name, which is not possible inside the Kolibri UI, and can cause layout issues.

### Expected behavior

`importusers` doesn't allow empty 'full name' fields.

### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->

…

### Context

Kolibri 0.13


Discussion:
`kolibri manage createsuperuser` will also have the same issue:

![2019-09-06_18-07-12](https://user-images.githubusercontent.com/1457929/64445001-baaca980-d0d5-11e9-930c-1cad907ad35e.png)

`createsuperuser` is a built-in management command in Django, so might need to do something different to enforce a non-empty full_name.
It would be different - but we already do some custom handling of this to make it work with the FacilityUser. The easiest thing would probably to make the `full_name` default to the `username`.

As it is editable after the fact, I think this would be a relatively harmless workaround.
@rtibbles 	Where is this "custom handling" done in the Kolibri code? At `auth.models.FacilityUser::create_superuser`?
Yup - https://github.com/learningequality/kolibri/blob/develop/kolibri/core/auth/models.py#L580 on the model manager rather than the model itself.

Adding in the fullname where superuser FacilityUser is created ought to do it: https://github.com/learningequality/kolibri/blob/develop/kolibri/core/auth/models.py#L592
> The easiest thing would probably to make the `full_name` default to the `username`.

Sounds like reasonable workaround to me, and makes it easier for the users!

Resolving patch:
diff --git a/kolibri/core/auth/management/commands/importusers.py b/kolibri/core/auth/management/commands/importusers.py
--- a/kolibri/core/auth/management/commands/importusers.py
+++ b/kolibri/core/auth/management/commands/importusers.py
@@ -119,9 +119,10 @@ def create_user(user, default_facility=None):
 
     except FacilityUser.DoesNotExist:
         password = user.get("password", DEFAULT_PASSWORD) or DEFAULT_PASSWORD
+        full_name = user.get("full_name", "") or username
         try:
             new_user = FacilityUser.objects.create_user(
-                full_name=user.get("full_name", ""),
+                full_name=full_name,
                 username=username,
                 facility=facility,
                 password=password,
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
@@ -591,7 +591,7 @@ def create_superuser(self, username, password):
 
         # create the new account in that facility
         superuser = FacilityUser(
-            username=username, password=password, facility=facility
+            full_name=username, username=username, password=password, facility=facility
         )
         superuser.full_clean()
         superuser.set_password(password)


Test patch:
diff --git a/kolibri/core/auth/test/test_user_import.py b/kolibri/core/auth/test/test_user_import.py
--- a/kolibri/core/auth/test/test_user_import.py
+++ b/kolibri/core/auth/test/test_user_import.py
@@ -4,6 +4,7 @@
 
 import csv
 import tempfile
+import os
 
 from django.core.management import call_command
 from django.core.management.base import CommandError
@@ -136,77 +137,87 @@ def test_create_user_not_exist_bad_username(self):
 ]
 
 
+class DeviceNotSetup(TestCase):
+    def test_device_not_setup(self):
+        csvfile, csvpath = tempfile.mkstemp(suffix="csv")
+        with self.assertRaisesRegexp(CommandError, "No default facility exists"):
+            call_command("importusers", csvpath)
+        os.remove(csvpath)
+
+
 class UserImportCommandTestCase(TestCase):
     """
-    Tests for userimport command.
+    Tests for 'kolibri manage importusers' command.
     """
 
+    @classmethod
+    def setUpClass(self):
+        super(UserImportCommandTestCase, self).setUpClass()
+        self.facility, self.superuser = setup_device()
+
     def setUp(self):
         self.csvfile, self.csvpath = tempfile.mkstemp(suffix="csv")
 
-    def test_device_not_setup(self):
-        with self.assertRaisesRegexp(CommandError, "No default facility exists"):
-            call_command("importusers", self.csvpath)
+    def tearDown(self):
+        FacilityUser.objects.exclude(username=self.superuser.username).delete()
+        os.remove(self.csvpath)
 
-    def test_setup_headers_no_username(self):
-        setup_device()
+    def importFromRows(self, *args):
         with open(self.csvpath, "w") as f:
             writer = csv.writer(f)
-            writer.writerow(["class", "facility"])
+            writer.writerows([a for a in args])
+
+        call_command("importusers", self.csvpath)
+
+    def test_setup_headers_no_username(self):
         with self.assertRaisesRegexp(CommandError, "No usernames specified"):
+            self.importFromRows(["class", "facility"])
             call_command("importusers", self.csvpath)
 
     def test_setup_headers_invalid_header(self):
-        setup_device()
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["class", "facility", "dogfood"])
         with self.assertRaisesRegexp(CommandError, "Mix of valid and invalid header"):
+            self.importFromRows(["class", "facility", "dogfood"])
             call_command("importusers", self.csvpath)
 
     def test_setup_headers_make_user(self):
-        setup_device()
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["username"])
-            writer.writerow(["testuser"])
+        self.importFromRows(["username"], ["testuser"])
         call_command("importusers", self.csvpath)
         self.assertTrue(FacilityUser.objects.filter(username="testuser").exists())
 
     def test_setup_no_headers_make_user(self):
-        setup_device()
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["Test user", "testuser"])
-        call_command("importusers", self.csvpath)
+        self.importFromRows(["Test user", "testuser"])
         self.assertTrue(FacilityUser.objects.filter(username="testuser").exists())
 
     def test_setup_no_headers_bad_user_good_user(self):
-        setup_device()
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["Test user", "testuser"])
-            writer.writerow(["Other user", "te$tuser"])
-        call_command("importusers", self.csvpath)
+        self.importFromRows(["Test user", "testuser"], ["Other user", "te$tuser"])
         self.assertTrue(FacilityUser.objects.filter(username="testuser").exists())
         self.assertFalse(FacilityUser.objects.filter(username="te$tuser").exists())
 
+    def test_empty_fullname_defaults_to_username(self):
+        self.importFromRows(["full_name", "username"], ["", "bob123"])
+        self.assertEqual(
+            FacilityUser.objects.get(username="bob123").full_name, "bob123"
+        )
+
+    def test_missing_fullname_defaults_to_username(self):
+        self.importFromRows(["username"], ["bob123"])
+        self.assertEqual(
+            FacilityUser.objects.get(username="bob123").full_name, "bob123"
+        )
+
     def test_update_valid_demographic_info_succeeds(self):
-        facility, superuser = setup_device()
         FacilityUser.objects.create(
             username="alice",
             birth_year="1990",
             gender="FEMALE",
             password="password",
-            facility=facility,
+            facility=self.facility,
+        )
+        self.importFromRows(
+            ["username", "birth_year", "gender"],
+            ["alice", "", "NOT_SPECIFIED"],
+            ["bob", "1970", "MALE"],
         )
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["username", "birth_year", "gender"])
-            writer.writerow(["alice", "", "NOT_SPECIFIED"])
-            writer.writerow(["bob", "1970", "MALE"])
-
-        call_command("importusers", self.csvpath)
         alice = FacilityUser.objects.get(username="alice")
         bob = FacilityUser.objects.get(username="bob")
         self.assertEqual(alice.birth_year, "")
@@ -215,21 +226,19 @@ def test_update_valid_demographic_info_succeeds(self):
         self.assertEqual(bob.gender, "MALE")
 
     def test_update_with_invalid_demographic_info_fails(self):
-        facility, superuser = setup_device()
         FacilityUser.objects.create(
             username="alice",
             birth_year="NOT_SPECIFIED",
             password="password",
-            facility=facility,
+            facility=self.facility,
+        )
+        self.importFromRows(
+            ["username", "birth_year", "gender"],
+            ["alice", "BLAH", "FEMALE"],
+            ["bob", "1970", "man"],
         )
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            writer.writerow(["username", "birth_year", "gender"])
-            writer.writerow(["alice", "BLAH", "FEMALE"])
-            writer.writerow(["bob", "1970", "man"])
 
-        call_command("importusers", self.csvpath)
-        # The entire update operation fails
+        # Alice and Bob's demographic data aren't updated
         alice = FacilityUser.objects.get(username="alice")
         bob = FacilityUser.objects.get(username="bob")
         self.assertEqual(alice.birth_year, "NOT_SPECIFIED")
@@ -238,31 +247,26 @@ def test_update_with_invalid_demographic_info_fails(self):
         self.assertEqual(bob.gender, "")
 
     def test_update_with_missing_columns(self):
-        facility, superuser = setup_device()
         FacilityUser.objects.create(
             username="alice",
             birth_year="1990",
             gender="FEMALE",
             id_number="ALICE",
             password="password",
-            facility=facility,
+            facility=self.facility,
+        )
+        # CSV is missing column for gender, so it should not be updated
+        self.importFromRows(
+            ["username", "birth_year", "id_number"], ["alice", "2000", ""]
         )
-        with open(self.csvpath, "w") as f:
-            writer = csv.writer(f)
-            # CSV is missing column for gender, so it should not be updated
-            writer.writerow(["username", "birth_year", "id_number"])
-            writer.writerow(["alice", "2000", ""])
-
-        call_command("importusers", self.csvpath)
         alice = FacilityUser.objects.get(username="alice")
         self.assertEqual(alice.gender, "FEMALE")
         self.assertEqual(alice.birth_year, "2000")
         self.assertEqual(alice.id_number, "")
 
     def test_import_from_export_csv(self):
-        facility, superuser = setup_device()
         for user in users:
-            FacilityUser.objects.create(facility=facility, **user)
+            FacilityUser.objects.create(facility=self.facility, **user)
         call_command(
             "exportusers", output_file=self.csvpath, overwrite=True, demographic=True
         )
@@ -275,9 +279,8 @@ def test_import_from_export_csv(self):
             self.assertEqual(user_model.id_number, "")
 
     def test_import_from_export_missing_headers(self):
-        facility, superuser = setup_device()
         for user in users:
-            FacilityUser.objects.create(facility=facility, **user)
+            FacilityUser.objects.create(facility=self.facility, **user)
         call_command(
             "exportusers", output_file=self.csvpath, overwrite=True, demographic=True
         )
@@ -305,9 +308,8 @@ def test_import_from_export_missing_headers(self):
             self.assertEqual(user_model.id_number, "")
 
     def test_import_from_export_mixed_headers(self):
-        facility, superuser = setup_device()
         for user in users:
-            FacilityUser.objects.create(facility=facility, **user)
+            FacilityUser.objects.create(facility=self.facility, **user)
         call_command(
             "exportusers", output_file=self.csvpath, overwrite=True, demographic=True
         )