Repository: oppia/oppia

Issue:
Design changes in UserSettingsModel
See [Design doc](https://docs.google.com/document/d/1sQagy3irkjB1Kt5sdb_KkOgxODt2m2ya0ZCoSJNlY4A/edit?usp=sharing) for **Full Remote Profile Support** Project!

Changing the design of **UserSettingsModel** to support "Profiles".

**Target PR date**: 23 July, 2020
**Target Completion date**: 27 July, 2020
Design changes in UserSettingsModel
See [Design doc](https://docs.google.com/document/d/1sQagy3irkjB1Kt5sdb_KkOgxODt2m2ya0ZCoSJNlY4A/edit?usp=sharing) for **Full Remote Profile Support** Project!

Changing the design of **UserSettingsModel** to support "Profiles".

**Target PR date**: 23 July, 2020
**Target Completion date**: 27 July, 2020


Discussion:

Resolving patch:
diff --git a/core/storage/user/gae_models.py b/core/storage/user/gae_models.py
--- a/core/storage/user/gae_models.py
+++ b/core/storage/user/gae_models.py
@@ -44,6 +44,8 @@ class UserSettingsModel(base_models.BaseModel):
     Instances of this class are keyed by the user id.
     """
 
+    # Attributes used for both full users and profile users.
+
     # User id used to identify user by GAE. Is not required for now because we
     # need to perform migration to fill this for existing users.
     gae_id = ndb.StringProperty(required=True, indexed=True)
@@ -55,25 +57,51 @@ class UserSettingsModel(base_models.BaseModel):
     # migration (to give role to all users) is run.
     role = ndb.StringProperty(
         required=True, indexed=True, default=feconf.ROLE_ID_EXPLORATION_EDITOR)
+    # When the user last agreed to the terms of the site. May be None.
+    last_agreed_to_terms = ndb.DateTimeProperty(default=None)
+    # When the user last logged in. This may be out-of-date by up to
+    # feconf.PROXIMAL_TIMEDELTA_SECS seconds.
+    last_logged_in = ndb.DateTimeProperty(default=None)
+    # Name of a user displayed in Android UI. Unlike username, it can be
+    # edited and is unique only among the profiles of the corresponding
+    # regular user account.
+    display_alias = ndb.StringProperty(default=None)
+    # User specified biography (to be shown on their profile page).
+    user_bio = ndb.TextProperty(indexed=False)
+    # User uploaded profile picture as a dataURI string. May be None.
+    profile_picture_data_url = ndb.TextProperty(default=None, indexed=False)
+    # Subject interests specified by the user.
+    subject_interests = ndb.StringProperty(repeated=True, indexed=True)
+    # When the user last edited an exploration.
+    # Exploration language preferences specified by the user.
+    # These language preferences are mainly for the purpose
+    # of figuring out what to show by default in the library index page.
+    preferred_language_codes = ndb.StringProperty(
+        repeated=True,
+        indexed=True,
+        choices=[lc['code'] for lc in constants.SUPPORTED_CONTENT_LANGUAGES])
+    # System language preference (for I18N).
+    preferred_site_language_code = ndb.StringProperty(
+        default=None, choices=[
+            language['id'] for language in constants.SUPPORTED_SITE_LANGUAGES])
+    # Audio language preference used for audio translations.
+    preferred_audio_language_code = ndb.StringProperty(
+        default=None, choices=[
+            language['id'] for language in constants.SUPPORTED_AUDIO_LANGUAGES])
+
+    # Attributes used for full users only.
+
     # Identifiable username to display in the UI. May be None.
     username = ndb.StringProperty(indexed=True)
     # Normalized username to use for duplicate-username queries. May be None.
     normalized_username = ndb.StringProperty(indexed=True)
-    # When the user last agreed to the terms of the site. May be None.
-    last_agreed_to_terms = ndb.DateTimeProperty(default=None)
     # When the user last started the state editor tutorial. May be None.
     last_started_state_editor_tutorial = ndb.DateTimeProperty(default=None)
     # When the user last started the state translation tutorial. May be None.
     last_started_state_translation_tutorial = ndb.DateTimeProperty(default=None)
-    # When the user last logged in. This may be out-of-date by up to
-    # feconf.PROXIMAL_TIMEDELTA_SECS seconds.
-    last_logged_in = ndb.DateTimeProperty(default=None)
-    # When the user last edited an exploration.
     last_edited_an_exploration = ndb.DateTimeProperty(default=None)
     # When the user last created an exploration.
     last_created_an_exploration = ndb.DateTimeProperty(default=None)
-    # User uploaded profile picture as a dataURI string. May be None.
-    profile_picture_data_url = ndb.TextProperty(default=None, indexed=False)
     # The preferred dashboard of the user.
     default_dashboard = ndb.StringProperty(
         default=constants.DASHBOARD_TYPE_LEARNER,
@@ -87,29 +115,9 @@ class UserSettingsModel(base_models.BaseModel):
         indexed=True,
         choices=list(
             constants.ALLOWED_CREATOR_DASHBOARD_DISPLAY_PREFS.values()))
-    # User specified biography (to be shown on their profile page).
-    user_bio = ndb.TextProperty(indexed=False)
-    # Subject interests specified by the user.
-    subject_interests = ndb.StringProperty(repeated=True, indexed=True)
     # The time, in milliseconds, when the user first contributed to Oppia.
     # May be None.
     first_contribution_msec = ndb.FloatProperty(default=None)
-    # Exploration language preferences specified by the user.
-    # TODO(sll): Add another field for the language that the user wants the
-    # site to display in. These language preferences are mainly for the purpose
-    # of figuring out what to show by default in the library index page.
-    preferred_language_codes = ndb.StringProperty(
-        repeated=True,
-        indexed=True,
-        choices=[lc['code'] for lc in constants.SUPPORTED_CONTENT_LANGUAGES])
-    # System language preference (for I18N).
-    preferred_site_language_code = ndb.StringProperty(
-        default=None, choices=[
-            language['id'] for language in constants.SUPPORTED_SITE_LANGUAGES])
-    # Audio language preference used for audio translations.
-    preferred_audio_language_code = ndb.StringProperty(
-        default=None, choices=[
-            language['id'] for language in constants.SUPPORTED_AUDIO_LANGUAGES])
 
     # DEPRECATED in 2.8.7. Do not use.
     gae_user_id = ndb.StringProperty(required=False, indexed=False)
@@ -199,7 +207,8 @@ def export_data(user_id):
             'first_contribution_msec': user.first_contribution_msec,
             'preferred_language_codes': user.preferred_language_codes,
             'preferred_site_language_code': user.preferred_site_language_code,
-            'preferred_audio_language_code': user.preferred_audio_language_code
+            'preferred_audio_language_code': user.preferred_audio_language_code,
+            'display_alias': user.display_alias
         }
 
     @classmethod
@@ -2225,8 +2234,8 @@ class UserAuthModel(base_models.BaseModel):
 
     # Authentication detail for sign-in using google id (GAE).
     gae_id = ndb.StringProperty(indexed=True)
-    # A code associated with profiles on android to provide a PIN based
-    # authentication within the account.
+    # A code associated with profile and full user on Android to provide a PIN
+    # based authentication within the account.
     pin = ndb.StringProperty(default=None)
 
     @staticmethod


Test patch:
diff --git a/core/domain/takeout_service_test.py b/core/domain/takeout_service_test.py
--- a/core/domain/takeout_service_test.py
+++ b/core/domain/takeout_service_test.py
@@ -68,6 +68,7 @@ class TakeoutServiceUnitTests(test_utils.GenericTestBase):
     GENERIC_USER_BIO = 'I am a user of Oppia!'
     GENERIC_SUBJECT_INTERESTS = ['Math', 'Science']
     GENERIC_LANGUAGE_CODES = ['en', 'es']
+    GENERIC_DISPLAY_ALIAS = 'display_alias'
     USER_1_IMPACT_SCORE = 0.87
     USER_1_TOTAL_PLAYS = 33
     USER_1_AVERAGE_RATINGS = 4.37
@@ -324,8 +325,9 @@ def set_up_non_trivial(self):
             subject_interests=self.GENERIC_SUBJECT_INTERESTS,
             first_contribution_msec=1,
             preferred_language_codes=self.GENERIC_LANGUAGE_CODES,
-            preferred_site_language_code=(self.GENERIC_LANGUAGE_CODES[0]),
-            preferred_audio_language_code=(self.GENERIC_LANGUAGE_CODES[0])
+            preferred_site_language_code=self.GENERIC_LANGUAGE_CODES[0],
+            preferred_audio_language_code=self.GENERIC_LANGUAGE_CODES[0],
+            display_alias=self.GENERIC_DISPLAY_ALIAS
         ).put()
 
         # Setup for GeneralFeedbackReplyToId.
@@ -479,7 +481,7 @@ def set_up_trivial(self):
         ).put()
         user_models.UserSubscriptionsModel(id=self.USER_ID_1).put()
 
-    def test_export_nonexistent_user(self):
+    def test_export_nonexistent_user_raises_error(self):
         """Setup for nonexistent user test of export_data functionality."""
         with self.assertRaisesRegexp(
             user_models.UserSettingsModel.EntityNotFoundError,
@@ -534,7 +536,8 @@ def test_export_data_trivial(self):
             'first_contribution_msec': None,
             'preferred_language_codes': [],
             'preferred_site_language_code': None,
-            'preferred_audio_language_code': None
+            'preferred_audio_language_code': None,
+            'display_alias': None
         }
         skill_data = {}
         stats_data = {}
@@ -808,7 +811,8 @@ def test_export_data_nontrivial(self):
             'first_contribution_msec': 1,
             'preferred_language_codes': self.GENERIC_LANGUAGE_CODES,
             'preferred_site_language_code': self.GENERIC_LANGUAGE_CODES[0],
-            'preferred_audio_language_code': self.GENERIC_LANGUAGE_CODES[0]
+            'preferred_audio_language_code': self.GENERIC_LANGUAGE_CODES[0],
+            'display_alias': self.GENERIC_DISPLAY_ALIAS
         }
 
         expected_reply_to_data = {
diff --git a/core/storage/user/gae_models_test.py b/core/storage/user/gae_models_test.py
--- a/core/storage/user/gae_models_test.py
+++ b/core/storage/user/gae_models_test.py
@@ -50,6 +50,10 @@ class UserSettingsModelTest(test_utils.GenericTestBase):
     USER_3_GAE_ID = 'gae3_id'
     USER_3_EMAIL = 'user3@example.com'
     USER_3_ROLE = feconf.ROLE_ID_ADMIN
+    PROFILE_1_ID = 'profile_id'
+    PROFILE_1_GAE_ID = 'gae_id'
+    PROFILE_1_EMAIL = 'user@example.com'
+    PROFILE_1_ROLE = feconf.ROLE_ID_LEARNER
     GENERIC_USERNAME = 'user'
     GENERIC_DATE = datetime.datetime(2019, 5, 20)
     GENERIC_EPOCH = utils.get_time_in_millisecs(datetime.datetime(2019, 5, 20))
@@ -57,6 +61,7 @@ class UserSettingsModelTest(test_utils.GenericTestBase):
     GENERIC_USER_BIO = 'I am a user of Oppia!'
     GENERIC_SUBJECT_INTERESTS = ['Math', 'Science']
     GENERIC_LANGUAGE_CODES = ['en', 'es']
+    GENERIC_DISPLAY_ALIAS = 'display_alias'
 
     def setUp(self):
         super(UserSettingsModelTest, self).setUp()
@@ -66,6 +71,12 @@ def setUp(self):
             email=self.USER_1_EMAIL,
             role=self.USER_1_ROLE
         ).put()
+        user_models.UserSettingsModel(
+            id=self.PROFILE_1_ID,
+            gae_id=self.PROFILE_1_GAE_ID,
+            email=self.PROFILE_1_EMAIL,
+            role=self.PROFILE_1_ROLE
+        ).put()
         user_models.UserSettingsModel(
             id=self.USER_2_ID,
             gae_id=self.USER_2_GAE_ID,
@@ -94,50 +105,85 @@ def setUp(self):
             subject_interests=self.GENERIC_SUBJECT_INTERESTS,
             first_contribution_msec=1,
             preferred_language_codes=self.GENERIC_LANGUAGE_CODES,
-            preferred_site_language_code=(self.GENERIC_LANGUAGE_CODES[0]),
-            preferred_audio_language_code=(self.GENERIC_LANGUAGE_CODES[0])
+            preferred_site_language_code=self.GENERIC_LANGUAGE_CODES[0],
+            preferred_audio_language_code=self.GENERIC_LANGUAGE_CODES[0],
+            display_alias=self.GENERIC_DISPLAY_ALIAS
         ).put()
 
-    def test_get_deletion_policy(self):
+    def test_get_deletion_policy_is_delete(self):
         self.assertEqual(
             user_models.UserSettingsModel.get_deletion_policy(),
             base_models.DELETION_POLICY.DELETE)
 
-    def test_apply_deletion_policy(self):
+    def test_apply_deletion_policy_for_registered_users_deletes_them(self):
+        # Case for a full user.
+        self.assertIsNotNone(
+            user_models.UserSettingsModel.get_by_id(self.USER_1_ID))
         user_models.UserSettingsModel.apply_deletion_policy(self.USER_1_ID)
         self.assertIsNone(
             user_models.UserSettingsModel.get_by_id(self.USER_1_ID))
-        # Test that calling apply_deletion_policy with no existing model
-        # doesn't fail.
+
+        # Case for a profile user.
+        self.assertIsNotNone(
+            user_models.UserSettingsModel.get_by_id(self.PROFILE_1_ID))
+        user_models.UserSettingsModel.apply_deletion_policy(self.PROFILE_1_ID)
+        self.assertIsNone(
+            user_models.UserSettingsModel.get_by_id(self.PROFILE_1_ID))
+
+    def test_apply_deletion_policy_for_banned_user_deletes_them(self):
+        self.assertIsNotNone(
+            user_models.UserSettingsModel.get_by_id(self.USER_2_ID))
+        user_models.UserSettingsModel.apply_deletion_policy(self.USER_2_ID)
+        self.assertIsNone(
+            user_models.UserSettingsModel.get_by_id(self.USER_2_ID))
+
+    def test_apply_deletion_policy_nonexistent_user_raises_no_exception(self):
+        self.assertIsNone(
+            user_models.UserSettingsModel.get_by_id(self.NONEXISTENT_USER_ID))
         user_models.UserSettingsModel.apply_deletion_policy(
             self.NONEXISTENT_USER_ID)
 
-    def test_has_reference_to_user_id(self):
+    def test_has_reference_to_registered_user_id_is_true(self):
+        # Case for a full user.
         self.assertTrue(
-            user_models.UserSettingsModel
-            .has_reference_to_user_id(self.USER_1_ID)
+            user_models.UserSettingsModel.has_reference_to_user_id(
+                self.USER_1_ID)
         )
+
+        # Case for a profile user.
         self.assertTrue(
-            user_models.UserSettingsModel
-            .has_reference_to_user_id(self.USER_2_ID)
+            user_models.UserSettingsModel.has_reference_to_user_id(
+                self.PROFILE_1_ID)
+        )
+
+        # Case for a banned full user.
+        self.assertTrue(
+            user_models.UserSettingsModel.has_reference_to_user_id(
+                self.USER_2_ID)
         )
+
+    def test_has_reference_to_non_existing_user_id_is_false(self):
         self.assertFalse(
             user_models.UserSettingsModel
             .has_reference_to_user_id(self.NONEXISTENT_USER_ID)
         )
 
-    def test_get_by_role(self):
-        user = user_models.UserSettingsModel.get_by_role(
-            feconf.ROLE_ID_ADMIN)
-        self.assertEqual(user[0].role, feconf.ROLE_ID_ADMIN)
+    def test_get_by_role_for_admin_returns_admin_users(self):
+        actual_users = [
+            user_models.UserSettingsModel.get_by_id(self.USER_1_ID),
+            user_models.UserSettingsModel.get_by_id(self.USER_3_ID)
+        ]
+        for user in user_models.UserSettingsModel.get_by_role(
+                feconf.ROLE_ID_ADMIN):
+            self.assertIn(user, actual_users)
 
-    def test_export_data_nonexistent_user(self):
+    def test_export_data_for_nonexistent_user_raises_exception(self):
         with self.assertRaisesRegexp(
             user_models.UserSettingsModel.EntityNotFoundError,
             'Entity for class UserSettingsModel with id fake_user not found'):
             user_models.UserSettingsModel.export_data('fake_user')
 
-    def test_export_data_trivial(self):
+    def test_export_data_for_trivial_case_returns_data_correctly(self):
         user = user_models.UserSettingsModel.get_by_id(self.USER_1_ID)
         user_data = user.export_data(user.id)
         expected_user_data = {
@@ -158,11 +204,12 @@ def test_export_data_trivial(self):
             'first_contribution_msec': None,
             'preferred_language_codes': [],
             'preferred_site_language_code': None,
-            'preferred_audio_language_code': None
+            'preferred_audio_language_code': None,
+            'display_alias': None
         }
         self.assertEqual(expected_user_data, user_data)
 
-    def test_export_data_nontrivial(self):
+    def test_export_data_for_nontrivial_case_returns_data_correctly(self):
         user = user_models.UserSettingsModel.get_by_id(self.USER_3_ID)
         user_data = user.export_data(user.id)
         expected_user_data = {
@@ -183,11 +230,12 @@ def test_export_data_nontrivial(self):
             'first_contribution_msec': 1,
             'preferred_language_codes': self.GENERIC_LANGUAGE_CODES,
             'preferred_site_language_code': self.GENERIC_LANGUAGE_CODES[0],
-            'preferred_audio_language_code': self.GENERIC_LANGUAGE_CODES[0]
+            'preferred_audio_language_code': self.GENERIC_LANGUAGE_CODES[0],
+            'display_alias': self.GENERIC_DISPLAY_ALIAS
         }
         self.assertEqual(expected_user_data, user_data)
 
-    def test_get_new_id_method_returns_unique_ids(self):
+    def test_get_new_id_under_normal_behaviour_returns_unique_ids(self):
         ids = set([])
         for _ in python_utils.RANGE(100):
             new_id = user_models.UserSettingsModel.get_new_id('')
@@ -196,7 +244,7 @@ def test_get_new_id_method_returns_unique_ids(self):
                 id=new_id, gae_id='gae_id', email='some@email.com').put()
             ids.add(new_id)
 
-    def test_create_raises_error_when_many_id_collisions_occur(self):
+    def test_get_new_id_for_too_many_collisions_raises_error(self):
         # Swap dependent method get_by_id to simulate collision every time.
         get_by_id_swap = self.swap(
             user_models.UserSettingsModel, 'get_by_id', types.MethodType(
diff --git a/core/tests/data/oppia_takeout_data.zip b/core/tests/data/oppia_takeout_data.zip
Binary files a/core/tests/data/oppia_takeout_data.zip and b/core/tests/data/oppia_takeout_data.zip differ