Repository: oppia/oppia

Issue:
Move PIN attribute from UserAuthDetailsModel to UserSettingsModel in the backend.
`PIN` isn't being used as a authentication detail anywhere in the backend and merely serves as a storage space on the server (it is useful for authentication only for profile login on android).
Hence it must be moved to the `UserSettingsModel` now.
Move PIN attribute from UserAuthDetailsModel to UserSettingsModel in the backend.
`PIN` isn't being used as a authentication detail anywhere in the backend and merely serves as a storage space on the server (it is useful for authentication only for profile login on android).
Hence it must be moved to the `UserSettingsModel` now.


Discussion:
@sajalasati 
Can I start working on this.
Hey @Shiv1202! I am working on this issue, so can you pick another one? 
If you are new I would suggest picking up an issue labelled a good first/second issue.
@sajalasati 
Can I start working on this.
Hey @Shiv1202! I am working on this issue, so can you pick another one? 
If you are new I would suggest picking up an issue labelled a good first/second issue.

Resolving patch:
diff --git a/core/domain/user_services.py b/core/domain/user_services.py
--- a/core/domain/user_services.py
+++ b/core/domain/user_services.py
@@ -82,6 +82,7 @@ class UserSettings(python_utils.OBJECT):
             preferences specified by the user.
         preferred_site_language_code: str or None. System language preference.
         preferred_audio_language_code: str or None. Audio language preference.
+        pin: str or None. The PIN of the user's profile for android.
     """
 
     def __init__(
@@ -94,7 +95,7 @@ def __init__(
                 constants.ALLOWED_CREATOR_DASHBOARD_DISPLAY_PREFS['CARD']),
             user_bio='', subject_interests=None, first_contribution_msec=None,
             preferred_language_codes=None, preferred_site_language_code=None,
-            preferred_audio_language_code=None, deleted=False):
+            preferred_audio_language_code=None, pin=None, deleted=False):
         """Constructs a UserSettings domain object.
 
         Args:
@@ -132,6 +133,7 @@ def __init__(
                 preference.
             preferred_audio_language_code: str or None. Default language used
                 for audio translations preference.
+            pin: str or None. The PIN of the user's profile for android.
             deleted: bool. Whether the user has requested removal of their
                 account.
         """
@@ -159,6 +161,7 @@ def __init__(
             preferred_language_codes if preferred_language_codes else [])
         self.preferred_site_language_code = preferred_site_language_code
         self.preferred_audio_language_code = preferred_audio_language_code
+        self.pin = pin
         self.deleted = deleted
 
     def validate(self):
@@ -188,6 +191,34 @@ def validate(self):
                 self.gae_id
             )
 
+        if not isinstance(self.role, python_utils.BASESTRING):
+            raise utils.ValidationError(
+                'Expected role to be a string, received %s' % self.role)
+        if self.role not in role_services.PARENT_ROLES:
+            raise utils.ValidationError('Role %s does not exist.' % self.role)
+
+        if self.pin is not None:
+            if not isinstance(self.pin, python_utils.BASESTRING):
+                raise utils.ValidationError(
+                    'Expected PIN to be a string, received %s' %
+                    self.pin
+                )
+            elif (len(self.pin) != feconf.FULL_USER_PIN_LENGTH and
+                  len(self.pin) != feconf.PROFILE_USER_PIN_LENGTH):
+                raise utils.ValidationError(
+                    'User PIN can only be of length %s or %s' %
+                    (
+                        feconf.FULL_USER_PIN_LENGTH,
+                        feconf.PROFILE_USER_PIN_LENGTH
+                    )
+                )
+            else:
+                for character in self.pin:
+                    if character < '0' or character > '9':
+                        raise utils.ValidationError(
+                            'Only numeric characters are allowed in PIN.'
+                        )
+
         if not isinstance(self.email, python_utils.BASESTRING):
             raise utils.ValidationError(
                 'Expected email to be a string, received %s' % self.email)
@@ -198,12 +229,6 @@ def validate(self):
             raise utils.ValidationError(
                 'Invalid email address: %s' % self.email)
 
-        if not isinstance(self.role, python_utils.BASESTRING):
-            raise utils.ValidationError(
-                'Expected role to be a string, received %s' % self.role)
-        if self.role not in role_services.PARENT_ROLES:
-            raise utils.ValidationError('Role %s does not exist.' % self.role)
-
         if not isinstance(
                 self.creator_dashboard_display_pref, python_utils.BASESTRING):
             raise utils.ValidationError(
@@ -303,20 +328,17 @@ class UserAuthDetails(python_utils.OBJECT):
     Attributes:
         user_id: str. The unique ID of the user.
         gae_id: str. The ID of the user retrieved from GAE.
-        pin: str or None. The PIN of the user's profile for android.
         parent_user_id: str or None. For profile users, the user ID of the full
             user associated with that profile. None for full users.
     """
 
     def __init__(
-            self, user_id, gae_id, pin=None, parent_user_id=None,
-            deleted=False):
+            self, user_id, gae_id, parent_user_id=None, deleted=False):
         """Constructs a UserAuthDetails domain object.
 
         Args:
             user_id: str. The unique ID of the user.
             gae_id: str. The ID of the user retrieved from GAE.
-            pin: str or None. The PIN of the user's profile for android.
             parent_user_id: str or None. For profile users, the user ID of the
                 full user associated with that profile. None for full users.
             deleted: bool. Whether the user has requested removal of their
@@ -324,7 +346,6 @@ def __init__(
         """
         self.user_id = user_id
         self.gae_id = gae_id
-        self.pin = pin
         self.parent_user_id = parent_user_id
         self.deleted = deleted
 
@@ -335,7 +356,6 @@ def validate(self):
         Raises:
             ValidationError. The user_id is not str.
             ValidationError. The gae_id is not str.
-            ValidationError. The pin is not str.
             ValidationError. The parent_user_id is not str.
         """
         if not isinstance(self.user_id, python_utils.BASESTRING):
@@ -353,13 +373,6 @@ def validate(self):
                 self.gae_id
             )
 
-        if (self.pin is not None and
-                not isinstance(self.pin, python_utils.BASESTRING)):
-            raise utils.ValidationError(
-                'Expected PIN to be a string, received %s' %
-                self.pin
-            )
-
         if (self.parent_user_id is not None and
                 not is_user_id_correct(self.parent_user_id)):
             raise utils.ValidationError(
@@ -841,6 +854,7 @@ def _save_user_settings(user_settings):
             user_settings.preferred_site_language_code),
         'preferred_audio_language_code': (
             user_settings.preferred_audio_language_code),
+        'pin': user_settings.pin,
         'deleted': user_settings.deleted
     }
 
@@ -895,6 +909,7 @@ def _transform_user_settings(user_settings_model):
             user_settings_model.preferred_site_language_code),
         preferred_audio_language_code=(
             user_settings_model.preferred_audio_language_code),
+        pin=user_settings_model.pin,
         deleted=user_settings_model.deleted
     )
 
@@ -985,7 +1000,6 @@ def _save_user_auth_details(user_auth_details):
 
     user_auth_details_dict = {
         'gae_id': user_auth_details.gae_id,
-        'pin': user_auth_details.pin,
         'parent_user_id': user_auth_details.parent_user_id,
         'deleted': user_auth_details.deleted
     }
@@ -1015,7 +1029,6 @@ def _get_user_auth_details_from_model(user_auth_details_model):
     return UserAuthDetails(
         user_id=user_auth_details_model.id,
         gae_id=user_auth_details_model.gae_id,
-        pin=user_auth_details_model.pin,
         parent_user_id=user_auth_details_model.parent_user_id,
         deleted=user_auth_details_model.deleted
     )
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
@@ -119,6 +119,9 @@ class UserSettingsModel(base_models.BaseModel):
     # The time, in milliseconds, when the user first contributed to Oppia.
     # May be None.
     first_contribution_msec = ndb.FloatProperty(default=None)
+    # A code associated with profile and full user on Android to provide a PIN
+    # based authentication within the account.
+    pin = ndb.StringProperty(default=None)
 
     # DEPRECATED in 2.8.7. Do not use.
     gae_user_id = ndb.StringProperty(required=False, indexed=False)
@@ -209,7 +212,8 @@ def export_data(user_id):
             '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,
-            'display_alias': user.display_alias
+            'display_alias': user.display_alias,
+            'pin': user.pin
         }
 
     @classmethod
@@ -2243,9 +2247,6 @@ class UserAuthDetailsModel(base_models.BaseModel):
     # Authentication detail for sign-in using google id (GAE). Exists only
     # for full users. None for profile users.
     gae_id = ndb.StringProperty(indexed=True)
-    # A code associated with profile and full user on Android to provide a PIN
-    # based authentication within the account.
-    pin = ndb.StringProperty(default=None)
     # For profile users, the user ID of the full user associated with that
     # profile. None for full users. Required for profiles because gae_id
     # attribute is None for them, hence this attribute stores their association
diff --git a/feconf.py b/feconf.py
--- a/feconf.py
+++ b/feconf.py
@@ -1056,7 +1056,7 @@ def get_empty_ratings():
 # CLASSROOM_PAGES_DATA property in config_domain.
 CLASSROOM_PAGES = ['math']
 
-# Authentication method using GAE ID (google sign in)
+# Authentication method using GAE ID (google sign in).
 AUTH_METHOD_GAE = 'gae'
 
 # TODO(#10501): Once domain objects can be imported by the storage layer, move
@@ -1206,3 +1206,7 @@ def get_empty_ratings():
     'optional_attribute_names': [],
     'user_id_attribute_names': []
 }]
+
+# Length of user PIN for different roles used on Android.
+FULL_USER_PIN_LENGTH = 5
+PROFILE_USER_PIN_LENGTH = 3


Test patch:
diff --git a/core/domain/prod_validators_test.py b/core/domain/prod_validators_test.py
--- a/core/domain/prod_validators_test.py
+++ b/core/domain/prod_validators_test.py
@@ -13147,8 +13147,6 @@ def test_missing_edited_exploration_model_failure(self):
 
 class UserAuthDetailsModelValidatorTests(test_utils.AuditJobsTestBase):
 
-    USER_PIN = '123'
-
     def setUp(self):
         super(UserAuthDetailsModelValidatorTests, self).setUp()
 
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
@@ -62,6 +62,7 @@ class TakeoutServiceUnitTests(test_utils.GenericTestBase):
     USER_1_ROLE = feconf.ROLE_ID_ADMIN
     USER_1_EMAIL = 'user1@example.com'
     GENERIC_USERNAME = 'user'
+    GENERIC_PIN = '12345'
     GENERIC_DATE = datetime.datetime(2019, 5, 20)
     GENERIC_EPOCH = utils.get_time_in_millisecs(GENERIC_DATE)
     GENERIC_IMAGE_URL = 'www.example.com/example.png'
@@ -327,7 +328,8 @@ def set_up_non_trivial(self):
             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],
-            display_alias=self.GENERIC_DISPLAY_ALIAS
+            display_alias=self.GENERIC_DISPLAY_ALIAS,
+            pin=self.GENERIC_PIN
         ).put()
 
         # Setup for GeneralFeedbackReplyToId.
@@ -537,7 +539,8 @@ def test_export_data_trivial(self):
             'preferred_language_codes': [],
             'preferred_site_language_code': None,
             'preferred_audio_language_code': None,
-            'display_alias': None
+            'display_alias': None,
+            'pin': None
         }
         skill_data = {}
         stats_data = {}
@@ -812,7 +815,8 @@ def test_export_data_nontrivial(self):
             '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],
-            'display_alias': self.GENERIC_DISPLAY_ALIAS
+            'display_alias': self.GENERIC_DISPLAY_ALIAS,
+            'pin': self.GENERIC_PIN
         }
 
         expected_reply_to_data = {
@@ -1011,10 +1015,11 @@ def test_export_data_nontrivial(self):
             self.USER_ID_1)
         observed_data = user_takeout_object.user_data
         observed_images = user_takeout_object.user_images
-        self.assertEqual(observed_data, expected_data)
+        self.assertItemsEqual(observed_data, expected_data)
         observed_json = json.dumps(observed_data)
         expected_json = json.dumps(expected_data)
-        self.assertEqual(json.loads(observed_json), json.loads(expected_json))
+        self.assertItemsEqual(
+            json.loads(observed_json), json.loads(expected_json))
         expected_images = [
             takeout_domain.TakeoutImage(
                 self.GENERIC_IMAGE_URL, 'user_settings_profile_picture.png')
diff --git a/core/domain/user_services_test.py b/core/domain/user_services_test.py
--- a/core/domain/user_services_test.py
+++ b/core/domain/user_services_test.py
@@ -1361,6 +1361,42 @@ def test_validate_non_str_gae_id(self):
         ):
             self.user_settings.validate()
 
+    def test_validate_non_str_pin_id(self):
+        self.user_settings.pin = 0
+        with self.assertRaisesRegexp(
+            utils.ValidationError, 'Expected PIN to be a string'
+        ):
+            self.user_settings.validate()
+
+    def test_validate_invalid_length_pin_raises_error(self):
+        invalid_pin_values_list = ['1', '12', '1234', '123@#6', 'ABCa', '1!#a']
+        error_msg = (
+            'User PIN can only be of length %s or %s' %
+            (feconf.FULL_USER_PIN_LENGTH, feconf.PROFILE_USER_PIN_LENGTH)
+        )
+        for pin in invalid_pin_values_list:
+            with self.assertRaisesRegexp(
+                utils.ValidationError, error_msg
+            ):
+                self.user_settings.pin = pin
+                self.user_settings.validate()
+
+    def test_validate_valid_length_with_numeric_char_pin_works_fine(self):
+        valid_pin_values_list = ['123', '12345', '764', '42343']
+        for pin in valid_pin_values_list:
+            self.user_settings.pin = pin
+            self.user_settings.validate()
+
+    def test_validate_valid_length_pin_with_non_numeric_char_raises_error(self):
+        valid_pin_values_list = ['AbC', '123A}', '1!2', 'AB!', '[123]']
+        error_msg = 'Only numeric characters are allowed in PIN'
+        for pin in valid_pin_values_list:
+            with self.assertRaisesRegexp(
+                utils.ValidationError, error_msg
+            ):
+                self.user_settings.pin = pin
+                self.user_settings.validate()
+
     def test_validate_empty_user_id(self):
         self.user_settings.user_id = ''
         with self.assertRaisesRegexp(
@@ -1553,13 +1589,6 @@ def test_validate_non_str_gae_id(self):
         ):
             self.user_auth_details.validate()
 
-    def test_validate_non_str_pin_id(self):
-        self.user_auth_details.pin = 0
-        with self.assertRaisesRegexp(
-            utils.ValidationError, 'Expected PIN to be a string'
-        ):
-            self.user_auth_details.validate()
-
     def test_parent_user_id_gae_id_together_raises_error(self):
         self.user_auth_details.parent_user_id = (
             user_models.UserSettingsModel.get_new_id(''))
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,7 @@ class UserSettingsModelTest(test_utils.GenericTestBase):
     USER_3_GAE_ID = 'gae3_id'
     USER_3_EMAIL = 'user3@example.com'
     USER_3_ROLE = feconf.ROLE_ID_ADMIN
+    GENERIC_PIN = '12345'
     PROFILE_1_ID = 'profile_id'
     PROFILE_1_GAE_ID = 'gae_id'
     PROFILE_1_EMAIL = 'user@example.com'
@@ -107,7 +108,8 @@ def setUp(self):
             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],
-            display_alias=self.GENERIC_DISPLAY_ALIAS
+            display_alias=self.GENERIC_DISPLAY_ALIAS,
+            pin=self.GENERIC_PIN
         ).put()
 
     def test_get_deletion_policy_is_delete(self):
@@ -205,7 +207,8 @@ def test_export_data_for_trivial_case_returns_data_correctly(self):
             'preferred_language_codes': [],
             'preferred_site_language_code': None,
             'preferred_audio_language_code': None,
-            'display_alias': None
+            'display_alias': None,
+            'pin': None
         }
         self.assertEqual(expected_user_data, user_data)
 
@@ -231,7 +234,8 @@ def test_export_data_for_nontrivial_case_returns_data_correctly(self):
             '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],
-            'display_alias': self.GENERIC_DISPLAY_ALIAS
+            'display_alias': self.GENERIC_DISPLAY_ALIAS,
+            'pin': self.GENERIC_PIN
         }
         self.assertEqual(expected_user_data, user_data)
 
@@ -2305,11 +2309,7 @@ class UserAuthDetailsModelTests(test_utils.GenericTestBase):
     NONREGISTERED_GAE_ID = 'gae_id_x'
     USER_ID = 'user_id'
     USER_GAE_ID = 'gae_id'
-    USER_PIN = '12345'
     PROFILE_ID = 'profile_id'
-    PROFILE_PIN = '123'
-    PROFILE_2_ID = 'profile2_id'
-    PROFILE_2_PIN = None
 
     def setUp(self):
         """Set up user models in datastore for use in testing."""
@@ -2318,18 +2318,10 @@ def setUp(self):
         user_models.UserAuthDetailsModel(
             id=self.USER_ID,
             gae_id=self.USER_GAE_ID,
-            pin=self.USER_PIN
         ).put()
         user_models.UserAuthDetailsModel(
             id=self.PROFILE_ID,
             gae_id=None,
-            pin=self.PROFILE_PIN,
-            parent_user_id=self.USER_ID
-        ).put()
-        user_models.UserAuthDetailsModel(
-            id=self.PROFILE_2_ID,
-            gae_id=None,
-            pin=self.PROFILE_2_PIN,
             parent_user_id=self.USER_ID
         ).put()
 
diff --git a/core/tests/data/oppia_takeout_data.zip b/core/tests/data/oppia_takeout_data.zip
new file mode 100644
Binary files /dev/null and b/core/tests/data/oppia_takeout_data.zip differ