Repository: oppia/oppia

Issue:
Fix `created_on` in `UserSettingsModel`
The `created_on` field contains a lot of invalid values because there was a bug in a way how we saved the `UserSettingModel`s (this was fixed in #9252).

We need to fix the `created_on` via one-off job. The job will look through other fields in `UserSettingModel` and through other models from the same user and compares them in order to find the lower date. This date is then set as the new `created_on`.

The fields to look through are:

- `last_agreed_to_terms`
- `last_started_state_editor_tutorial`
- `last_started_state_translation_tutorial`
- `last_logged_in`
- `last_edited_an_exploration`
- `last_created_an_exploration`

Other models to look through are mostly from https://github.com/oppia/oppia/blob/develop/core/storage/user/gae_models.py
Fix `created_on` in `UserSettingsModel`
The `created_on` field contains a lot of invalid values because there was a bug in a way how we saved the `UserSettingModel`s (this was fixed in #9252).

We need to fix the `created_on` via one-off job. The job will look through other fields in `UserSettingModel` and through other models from the same user and compares them in order to find the lower date. This date is then set as the new `created_on`.

The fields to look through are:

- `last_agreed_to_terms`
- `last_started_state_editor_tutorial`
- `last_started_state_translation_tutorial`
- `last_logged_in`
- `last_edited_an_exploration`
- `last_created_an_exploration`

Other models to look through are mostly from https://github.com/oppia/oppia/blob/develop/core/storage/user/gae_models.py


Discussion:
Hi @vojtechjelinek  I want to work on this issue.
@itsmayank0 Hi, this issue is not recommended for new contributors. Please pick one of these https://github.com/oppia/oppia/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22good+first+issue%22 issues to take up as a new contributor.
@Showtim3 How is this going?
Unassigning @Showtim3 for now.
Hi @vojtechjelinek ! Just had a brief look at the issue, if no one is working on this currently, can I start looking into it?
@sajalasati Yes, you can, I have assigned you.
@vojtechjelinek Some questions regarding the issue:
1) Should I also check the attributes `first_contribution_msec` and `last_logged_in` in `UserSettingsModel` to see if they have lower values? `last_logged_in` might not be a great choice, but the other one could be. What do you think?
2) In general when going through other user models which are linked to `UserSettingsModel`, should I only check their `created_on` and `last_updated` attributes, or should I also look into model specific "DateTime" attributes for all models, that could also have potentially correct information about the time we want. For example, should I also look into:
  a) `UserSubscriptionsModel`'s attribute `last_checked`
  b) `ExplorationUserDataModel`'s attributes `rated_on`, `draft_change_list_last_updated` and so on for other models while obtaining the min timestamp value.
3) Almost all the models in `core/storage/user/gae_models.py` can be associated with `UserSettingsModel` - either they are keyed by `user_id` only or have `user_id` as an attribute. Only following models are exceptions and can be skipped during this check - `UserQueryModel` (didn't fully understand how this works), `DeletedUserModel`, `PseudonymizedUserModel`, `DeletedUsernameModel`. Please correct me if I am wrong.

Thanks!
>1. Should I also check the attributes `first_contribution_msec` and `last_logged_in` in `UserSettingsModel` to see if they have lower values? `last_logged_in` might not be a great choice, but the other one could be. What do you think?

Yes, makes sense to look in these.


>2. In general when going through other user models which are linked to `UserSettingsModel`, should I only check their `created_on` and `last_updated` attributes, or should I also look into model specific "DateTime" attributes for all models, that could also have potentially correct information about the time we want. For example, should I also look into:
>        a) `UserSubscriptionsModel`'s attribute `last_checked`
>        b) `ExplorationUserDataModel`'s attributes `rated_on`, `draft_change_list_last_updated` and so on for other models while obtaining the min timestamp value.

Yes, you can also check these.

>3. Almost all the models in `core/storage/user/gae_models.py` can be associated with `UserSettingsModel` - either they are keyed by `user_id` only or have `user_id` as an attribute. Only following models are exceptions and can be skipped during this check - `UserQueryModel` (didn't fully understand how this works), `DeletedUserModel`, `PseudonymizedUserModel`, `DeletedUsernameModel`. Please correct me if I am wrong.

Yes, makes sense to skip these.


Hi @vojtechjelinek  I want to work on this issue.
@itsmayank0 Hi, this issue is not recommended for new contributors. Please pick one of these https://github.com/oppia/oppia/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22good+first+issue%22 issues to take up as a new contributor.
@Showtim3 How is this going?
Unassigning @Showtim3 for now.
Hi @vojtechjelinek ! Just had a brief look at the issue, if no one is working on this currently, can I start looking into it?
@sajalasati Yes, you can, I have assigned you.
@vojtechjelinek Some questions regarding the issue:
1) Should I also check the attributes `first_contribution_msec` and `last_logged_in` in `UserSettingsModel` to see if they have lower values? `last_logged_in` might not be a great choice, but the other one could be. What do you think?
2) In general when going through other user models which are linked to `UserSettingsModel`, should I only check their `created_on` and `last_updated` attributes, or should I also look into model specific "DateTime" attributes for all models, that could also have potentially correct information about the time we want. For example, should I also look into:
  a) `UserSubscriptionsModel`'s attribute `last_checked`
  b) `ExplorationUserDataModel`'s attributes `rated_on`, `draft_change_list_last_updated` and so on for other models while obtaining the min timestamp value.
3) Almost all the models in `core/storage/user/gae_models.py` can be associated with `UserSettingsModel` - either they are keyed by `user_id` only or have `user_id` as an attribute. Only following models are exceptions and can be skipped during this check - `UserQueryModel` (didn't fully understand how this works), `DeletedUserModel`, `PseudonymizedUserModel`, `DeletedUsernameModel`. Please correct me if I am wrong.

Thanks!
>1. Should I also check the attributes `first_contribution_msec` and `last_logged_in` in `UserSettingsModel` to see if they have lower values? `last_logged_in` might not be a great choice, but the other one could be. What do you think?

Yes, makes sense to look in these.


>2. In general when going through other user models which are linked to `UserSettingsModel`, should I only check their `created_on` and `last_updated` attributes, or should I also look into model specific "DateTime" attributes for all models, that could also have potentially correct information about the time we want. For example, should I also look into:
>        a) `UserSubscriptionsModel`'s attribute `last_checked`
>        b) `ExplorationUserDataModel`'s attributes `rated_on`, `draft_change_list_last_updated` and so on for other models while obtaining the min timestamp value.

Yes, you can also check these.

>3. Almost all the models in `core/storage/user/gae_models.py` can be associated with `UserSettingsModel` - either they are keyed by `user_id` only or have `user_id` as an attribute. Only following models are exceptions and can be skipped during this check - `UserQueryModel` (didn't fully understand how this works), `DeletedUserModel`, `PseudonymizedUserModel`, `DeletedUsernameModel`. Please correct me if I am wrong.

Yes, makes sense to skip these.



Resolving patch:
diff --git a/core/domain/user_jobs_one_off.py b/core/domain/user_jobs_one_off.py
--- a/core/domain/user_jobs_one_off.py
+++ b/core/domain/user_jobs_one_off.py
@@ -19,6 +19,7 @@
 
 import ast
 import copy
+import datetime
 import imghdr
 
 from core import jobs
@@ -468,6 +469,325 @@ def reduce(key, values):
         yield (key, len(values))
 
 
+class FixUserSettingsCreatedOnOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """Job that fixes the invalid values of created_on attribute in the
+    UserSettingsModel.
+    It is a one-off job and can be removed from the codebase after we resolve
+    this issue by running the job once in the January 2021 release.
+    """
+
+    @classmethod
+    def enqueue(cls, job_id, additional_job_params=None):
+        super(FixUserSettingsCreatedOnOneOffJob, cls).enqueue(
+            job_id, shard_count=64)
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [user_models.UserSettingsModel]
+
+    @staticmethod
+    def map(user_settings_model):
+        user_id = user_settings_model.id
+        user_dates_list = [
+            (
+                'UserSettingsModel_last_updated',
+                user_settings_model.last_updated
+            ),
+            (
+                'UserSettingsModel_last_agreed_to_terms',
+                user_settings_model.last_agreed_to_terms
+            ),
+            (
+                'UserSettingsModel_last_started_state_editor_tutorial',
+                user_settings_model.last_started_state_editor_tutorial
+            ),
+            (
+                'UserSettingsModel_last_started_state_translation_tutorial',
+                user_settings_model.last_started_state_translation_tutorial
+            ),
+            (
+                'UserSettingsModel_last_logged_in',
+                user_settings_model.last_logged_in
+            ),
+            (
+                'UserSettingsModel_last_edited_an_exploration',
+                user_settings_model.last_edited_an_exploration
+            ),
+            (
+                'UserSettingsModel_last_created_an_exploration',
+                user_settings_model.last_created_an_exploration
+            ),
+        ]
+
+        if user_settings_model.first_contribution_msec is not None:
+            user_dates_list.append(
+                (
+                    'UserSettingsModel_first_contribution_msec',
+                    datetime.datetime.fromtimestamp(
+                        python_utils.divide(
+                            user_settings_model.first_contribution_msec, 1000.0)
+                    )
+                )
+            )
+
+        # Models in user storage module having user_id as an attribute.
+        exploration_user_data_model = (
+            user_models.ExplorationUserDataModel.query(
+                user_models.ExplorationUserDataModel.user_id == user_id).get()
+        )
+
+        all_models_linked_with_user_settings_model = [
+            ('ExplorationUserDataModel', exploration_user_data_model)
+        ]
+
+        # Models in user storage module keyed by user_id itself.
+        model_names_and_ids_to_be_fetched_in_batch = [
+            ('UserContributionsModel', [user_id]),
+            ('UserEmailPreferencesModel', [user_id]),
+            ('UserStatsModel', [user_id]),
+            ('UserSubscriptionsModel', [user_id]),
+        ]
+        fetched_batch_models = (
+            datastore_services.fetch_multiple_entities_by_ids_and_models(
+                model_names_and_ids_to_be_fetched_in_batch)
+        )
+
+        for model_name_tuple, model_list in list(python_utils.ZIP(
+                model_names_and_ids_to_be_fetched_in_batch,
+                fetched_batch_models)):
+            model_name = model_name_tuple[0]
+            actual_model = model_list[0]
+            all_models_linked_with_user_settings_model.append(
+                (model_name, actual_model)
+            )
+
+        for model_name, model in all_models_linked_with_user_settings_model:
+            if model is not None:
+                user_dates_list.append(
+                    (
+                        model_name + python_utils.UNICODE('_last_updated'),
+                        model.last_updated
+                    )
+                )
+                user_dates_list.append(
+                    (
+                        model_name + python_utils.UNICODE('_created_on'),
+                        model.created_on
+                    )
+                )
+                if model_name == 'UserSubscriptionsModel':
+                    user_dates_list.append(
+                        (
+                            'UserSubscriptionsModel_last_checked',
+                            model.last_checked
+                        )
+                    )
+                if model_name == 'ExplorationUserDataModel':
+                    user_dates_list.append(
+                        (
+                            'ExplorationUserDataModel_rated_on',
+                            model.rated_on
+                        )
+                    )
+                    user_dates_list.append(
+                        (
+                            'ExplorationUserDataModel_draft_change_list_last_'
+                            'updated',
+                            model.draft_change_list_last_updated
+                        )
+                    )
+
+        filtered_user_dates_list = [
+            (attribute_name, date) for attribute_name, date in user_dates_list
+            if date is not None
+        ]
+        model_name, min_date = min(filtered_user_dates_list, key=lambda x: x[1])
+        time_delta_for_update = datetime.timedelta(minutes=5)
+
+        # This method for converting date_time_string to datettime object has
+        # also been used here:
+        # https://github.com/oppia/oppia/blob/d394b6a186acc74b5ec9c3fecc20cc3f1954f441/utils.py#L479
+        correction_cutoff_timestamp = datetime.datetime.strptime(
+            'Jul 1 2020', '%b %d %Y')
+        if user_settings_model.created_on - min_date > time_delta_for_update:
+            user_settings_model.update_timestamps(
+                update_last_updated_time=False)
+            user_settings_model.created_on = min_date
+            user_settings_model.put()
+            yield (
+                'SUCCESS_UPDATED_USING_' + python_utils.UNICODE(model_name), 1)
+
+            # Yield an additional error key for user_models created after
+            # cutoff date July 1, 2020 and having a discrepancy in their
+            # created_on.
+            if min_date >= correction_cutoff_timestamp:
+                yield ('ERROR_NOT_UP_TO_DATE_USER', user_id)
+        else:
+            yield ('SUCCESS_ALREADY_UP_TO_DATE', 1)
+
+    @staticmethod
+    def reduce(key, values):
+        """Implements the reduce function for this job."""
+        if key == 'ERROR_NOT_UP_TO_DATE_USER':
+            yield (key, values)
+        else:
+            yield (key, len(values))
+
+
+class UserSettingsCreatedOnAuditOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """Job that audits the value of created_on attribute in the
+    UserSettingsModel. This one-off job can be removed after we have verified
+    that all UserSettingsModels have their created_on set correctly.
+    """
+
+    @classmethod
+    def enqueue(cls, job_id, additional_job_params=None):
+        super(UserSettingsCreatedOnAuditOneOffJob, cls).enqueue(
+            job_id, shard_count=64)
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [user_models.UserSettingsModel]
+
+    @staticmethod
+    def map(user_settings_model):
+        user_id = user_settings_model.id
+        user_dates_list = [
+            (
+                'UserSettingsModel_created_on',
+                user_settings_model.created_on
+            ),
+            (
+                'UserSettingsModel_last_updated',
+                user_settings_model.last_updated
+            ),
+            (
+                'UserSettingsModel_last_agreed_to_terms',
+                user_settings_model.last_agreed_to_terms
+            ),
+            (
+                'UserSettingsModel_last_started_state_editor_tutorial',
+                user_settings_model.last_started_state_editor_tutorial
+            ),
+            (
+                'UserSettingsModel_last_started_state_translation_tutorial',
+                user_settings_model.last_started_state_translation_tutorial
+            ),
+            (
+                'UserSettingsModel_last_logged_in',
+                user_settings_model.last_logged_in
+            ),
+            (
+                'UserSettingsModel_last_edited_an_exploration',
+                user_settings_model.last_edited_an_exploration
+            ),
+            (
+                'UserSettingsModel_last_created_an_exploration',
+                user_settings_model.last_created_an_exploration
+            ),
+        ]
+
+        if user_settings_model.first_contribution_msec is not None:
+            user_dates_list.append(
+                (
+                    'UserSettingsModel_first_contribution_msec',
+                    datetime.datetime.fromtimestamp(
+                        python_utils.divide(
+                            user_settings_model.first_contribution_msec, 1000.0)
+                    )
+                )
+            )
+
+        # Models in user storage module having user_id as an attribute.
+        exploration_user_data_model = (
+            user_models.ExplorationUserDataModel.query(
+                user_models.ExplorationUserDataModel.user_id == user_id).get()
+        )
+
+        all_models_linked_with_user_settings_model = [
+            ('ExplorationUserDataModel', exploration_user_data_model)
+        ]
+
+        # Models in user storage module keyed by user_id.
+        model_names_and_ids_to_be_fetched_in_batch = [
+            ('UserContributionsModel', [user_id]),
+            ('UserEmailPreferencesModel', [user_id]),
+            ('UserStatsModel', [user_id]),
+            ('UserSubscriptionsModel', [user_id]),
+        ]
+        fetched_batch_models = (
+            datastore_services.fetch_multiple_entities_by_ids_and_models(
+                model_names_and_ids_to_be_fetched_in_batch)
+        )
+
+        for model_name_tuple, model_list in list(python_utils.ZIP(
+                model_names_and_ids_to_be_fetched_in_batch,
+                fetched_batch_models)):
+            model_name = model_name_tuple[0]
+            actual_model = model_list[0]
+            all_models_linked_with_user_settings_model.append(
+                (model_name, actual_model)
+            )
+
+        for model_name, model in all_models_linked_with_user_settings_model:
+            if model is not None:
+                user_dates_list.append(
+                    (
+                        model_name + python_utils.UNICODE('_last_updated'),
+                        model.last_updated
+                    )
+                )
+                user_dates_list.append(
+                    (
+                        model_name + python_utils.UNICODE('_created_on'),
+                        model.created_on
+                    )
+                )
+                if model_name == 'UserSubscriptionsModel':
+                    user_dates_list.append(
+                        (
+                            'UserSubscriptionsModel_last_checked',
+                            model.last_checked
+                        )
+                    )
+                if model_name == 'ExplorationUserDataModel':
+                    user_dates_list.append(
+                        (
+                            'ExplorationUserDataModel_rated_on',
+                            model.rated_on
+                        )
+                    )
+                    user_dates_list.append(
+                        (
+                            'ExplorationUserDataModel_draft_change_list_last_'
+                            'updated',
+                            model.draft_change_list_last_updated
+                        )
+                    )
+
+        filtered_user_dates_list = [
+            (attribute_name, date) for attribute_name, date in user_dates_list
+            if date is not None
+        ]
+        model_name, min_date = min(filtered_user_dates_list, key=lambda x: x[1])
+        time_delta_for_correctness = datetime.timedelta(minutes=5)
+        if user_settings_model.created_on - min_date > (
+                time_delta_for_correctness):
+            yield (
+                'ERROR_NEED_TO_UPDATE_USING_' + python_utils.UNICODE(
+                    model_name), user_id)
+        else:
+            yield ('SUCCESS_ALREADY_UP_TO_DATE', 1)
+
+    @staticmethod
+    def reduce(key, values):
+        """Implements the reduce function for this job."""
+        if key.startswith('ERROR_NEED_TO_UPDATE_USING'):
+            yield (key, values)
+        else:
+            yield (key, len(values))
+
+
 class CleanUpUserSubscribersModelOneOffJob(jobs.BaseMapReduceOneOffJobManager):
     """Job that cleans up UserSubscribersModel by removing user id if it is
     present in subscriber ids.
diff --git a/core/jobs_registry.py b/core/jobs_registry.py
--- a/core/jobs_registry.py
+++ b/core/jobs_registry.py
@@ -127,6 +127,8 @@
     user_jobs_one_off.CleanUpUserContributionsModelOneOffJob,
     user_jobs_one_off.ProfilePictureAuditOneOffJob,
     user_jobs_one_off.UniqueHashedNormalizedUsernameAuditJob,
+    user_jobs_one_off.FixUserSettingsCreatedOnOneOffJob,
+    user_jobs_one_off.UserSettingsCreatedOnAuditOneOffJob
 ]
 
 # List of all manager classes for prod validation one-off batch jobs for which


Test patch:
diff --git a/core/domain/user_jobs_one_off_test.py b/core/domain/user_jobs_one_off_test.py
--- a/core/domain/user_jobs_one_off_test.py
+++ b/core/domain/user_jobs_one_off_test.py
@@ -1688,6 +1688,487 @@ def test_one_subscription_model_without_feedback_thread_ids(self):
             migrated_subscription_model.last_updated)
 
 
+class FixUserSettingsCreatedOnOneOffJobTests(test_utils.GenericTestBase):
+
+    USER_ID_1 = 'user_id'
+    USER_ID_2 = 'user_id_2'
+    EMAIL_1 = 'test@email.com'
+    EMAIL_2 = 'test2@email.com'
+    SKILL_ID_1 = 'skill_id_1'
+    SKILL_ID_2 = 'skill_id_2'
+    DEGREE_OF_MASTERY = 0.5
+    EXPLORATION_IDS = ['exp_1', 'exp_2', 'exp_3']
+    COLLECTION_IDS = ['col_1', 'col_2', 'col_3']
+    EXP_ID_ONE = 'exp_id_one'
+    EXP_ID_TWO = 'exp_id_two'
+    EXP_ID_THREE = 'exp_id_three'
+
+    def setUp(self):
+
+        def empty(*_):
+            """Function that takes any number of arguments and does nothing."""
+            pass
+
+        # We don't want to sign up the superadmin user.
+        with self.swap(
+            test_utils.AppEngineTestBase, 'signup_superadmin_user', empty):
+            super(FixUserSettingsCreatedOnOneOffJobTests, self).setUp()
+
+    def _run_one_off_job(self):
+        """Runs the one-off MapReduce job."""
+        job_id = (
+            user_jobs_one_off.FixUserSettingsCreatedOnOneOffJob.create_new())
+        user_jobs_one_off.FixUserSettingsCreatedOnOneOffJob.enqueue(job_id)
+        self.assertEqual(
+            self.count_jobs_in_mapreduce_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_mapreduce_tasks()
+        stringified_output = (
+            user_jobs_one_off.FixUserSettingsCreatedOnOneOffJob
+            .get_output(job_id))
+        eval_output = [ast.literal_eval(stringified_item) for
+                       stringified_item in stringified_output]
+        sorted_eval_output = []
+        for key, values in eval_output:
+            if key == 'ERROR_NOT_UP_TO_DATE_USER':
+                values.sort()
+            sorted_eval_output.append([key, values])
+        return sorted_eval_output
+
+    def test_update_user_model_using_all_user_settings_model_attributes(self):
+        user_settings_model = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        user_settings_model.update_timestamps()
+        original_created_on_timestamp = user_settings_model.created_on
+        # last_agreed_to_terms is set to have the absolute minimum
+        # timestamp value.
+        user_settings_model.last_agreed_to_terms = (
+            original_created_on_timestamp + datetime.timedelta(hours=2))
+        final_created_on_timestamp = user_settings_model.last_agreed_to_terms
+        user_settings_model.created_on = (
+            final_created_on_timestamp + datetime.timedelta(days=10))
+        user_settings_model.last_logged_in = (
+            final_created_on_timestamp + datetime.timedelta(minutes=1))
+        user_settings_model.last_started_state_editor_tutorial = (
+            final_created_on_timestamp + datetime.timedelta(minutes=3))
+        user_settings_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=12))
+        user_settings_model.last_started_state_translation_tutorial = (
+            final_created_on_timestamp + datetime.timedelta(hours=14))
+        user_settings_model.last_edited_an_exploration = (
+            final_created_on_timestamp + datetime.timedelta(hours=15))
+        user_settings_model.last_created_an_exploration = (
+            final_created_on_timestamp + datetime.timedelta(hours=16))
+        user_settings_model.first_contribution_msec = (
+            utils.get_time_in_millisecs(
+                final_created_on_timestamp + datetime.timedelta(hours=10))
+        )
+        user_settings_model.put()
+
+        expected_output = [
+            [
+                'SUCCESS_UPDATED_USING_UserSettingsModel_last_agreed_to_terms',
+                1
+            ],
+            ['ERROR_NOT_UP_TO_DATE_USER', [self.USER_ID_1]]
+        ]
+        self.assertLess(
+            final_created_on_timestamp, user_settings_model.created_on)
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+        migrated_user_model = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_1))
+        self.assertEqual(
+            migrated_user_model.created_on, final_created_on_timestamp)
+
+    def test_update_using_datetime_attributes_of_all_other_models(self):
+        user_subscriptions_model = user_models.UserSubscriptionsModel(
+            id=self.USER_ID_1)
+        user_subscriptions_model.update_timestamps()
+        # We are sequentially creating the models, so the timestamps will
+        # be in increasing order, and hence created_on attribute for
+        # user_subscriptions_model will have the smallest timestamp value.
+        final_created_on_timestamp = user_subscriptions_model.created_on
+        user_subscriptions_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=2)
+        )
+        user_subscriptions_model.last_checked = (
+            final_created_on_timestamp + datetime.timedelta(hours=3)
+        )
+        user_subscriptions_model.put()
+
+        user_settings_model = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        user_settings_model.update_timestamps()
+        user_settings_model.created_on = (
+            final_created_on_timestamp + datetime.timedelta(hours=10)
+        )
+        user_settings_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=10)
+        )
+        user_settings_model.put()
+
+        exploration_user_data_model = user_models.ExplorationUserDataModel(
+            id='%s.%s' % (self.USER_ID_1, self.EXP_ID_ONE),
+            user_id=self.USER_ID_1,
+            exploration_id=self.EXP_ID_ONE,
+            rating=2,
+            rated_on=final_created_on_timestamp + datetime.timedelta(hours=1),
+            draft_change_list={'new_content': {}},
+            draft_change_list_last_updated=(
+                final_created_on_timestamp + datetime.timedelta(hours=2)),
+            draft_change_list_exp_version=3,
+            draft_change_list_id=1
+        )
+        exploration_user_data_model.update_timestamps()
+        exploration_user_data_model.created_on = (
+            final_created_on_timestamp + datetime.timedelta(hours=5)
+        )
+        exploration_user_data_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=5)
+        )
+        exploration_user_data_model.put()
+
+        user_contributions_model = user_models.UserContributionsModel(
+            id=self.USER_ID_1)
+        user_contributions_model.update_timestamps()
+        user_contributions_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=5)
+        )
+        user_contributions_model.put()
+
+        user_email_preferences_model = user_models.UserEmailPreferencesModel(
+            id=self.USER_ID_1)
+        user_email_preferences_model.update_timestamps()
+        user_email_preferences_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=6)
+        )
+        user_email_preferences_model.put()
+
+        user_stats_model = user_models.UserStatsModel(
+            id=self.USER_ID_1)
+        user_stats_model.update_timestamps()
+        user_stats_model.created_on = (
+            final_created_on_timestamp + datetime.timedelta(hours=10)
+        )
+        user_stats_model.last_updated = (
+            final_created_on_timestamp + datetime.timedelta(hours=10)
+        )
+        user_stats_model.put()
+
+        expected_output = [
+            [
+                'SUCCESS_UPDATED_USING_UserSubscriptionsModel_created_on', 1
+            ],
+            ['ERROR_NOT_UP_TO_DATE_USER', [self.USER_ID_1]]
+        ]
+        self.assertLess(
+            final_created_on_timestamp, user_settings_model.created_on)
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+        migrated_user_model = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_1))
+        self.assertEqual(
+            migrated_user_model.created_on, final_created_on_timestamp)
+
+    def test_time_difference_less_than_time_delta_does_not_update(self):
+        user_auth_details_model = (
+            user_models.UserAuthDetailsModel(
+                id=self.USER_ID_1,
+                gae_id='gae_id'
+            )
+        )
+        user_auth_details_model.update_timestamps()
+        user_auth_details_model.put()
+
+        user_settings_model = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        user_settings_model.update_timestamps()
+        user_settings_model.put()
+
+        # UserAuthDetails model was created before UserSettingsModel, but the
+        # time difference is less than the time_delta required (will be less
+        # than a second here), hence created_on will not be updated.
+        self.assertLess(
+            user_auth_details_model.created_on, user_settings_model.created_on)
+
+        expected_output = [['SUCCESS_ALREADY_UP_TO_DATE', 1]]
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+        migrated_user_model = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_1))
+        self.assertNotEqual(
+            migrated_user_model.created_on, user_auth_details_model.created_on)
+
+    def test_update_for_multiple_users_works_correctly(self):
+        user_settings_model_1 = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        user_settings_model_1.update_timestamps()
+        user_settings_model_1.created_on += datetime.timedelta(hours=10)
+        final_created_on_timestamp_1 = user_settings_model_1.last_updated
+        user_settings_model_1.put()
+
+        user_settings_model_2 = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_2,
+                email=self.EMAIL_2,
+            )
+        )
+        user_settings_model_2.update_timestamps()
+        original_created_on_timestamp_2 = user_settings_model_2.created_on
+        user_settings_model_2.created_on = (
+            original_created_on_timestamp_2 + datetime.timedelta(hours=5))
+        user_settings_model_2.last_updated = (
+            original_created_on_timestamp_2 + datetime.timedelta(hours=6))
+        user_settings_model_2.last_logged_in = (
+            original_created_on_timestamp_2 + datetime.timedelta(hours=1))
+        final_created_on_timestamp_2 = user_settings_model_2.last_logged_in
+        user_settings_model_2.put()
+
+        expected_output = [
+            ['SUCCESS_UPDATED_USING_UserSettingsModel_last_updated', 1],
+            ['SUCCESS_UPDATED_USING_UserSettingsModel_last_logged_in', 1],
+            ['ERROR_NOT_UP_TO_DATE_USER', [self.USER_ID_1, self.USER_ID_2]]
+        ]
+        self.assertLess(
+            final_created_on_timestamp_1, user_settings_model_1.created_on)
+        self.assertLess(
+            final_created_on_timestamp_2, user_settings_model_2.created_on)
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(actual_output, expected_output)
+        migrated_user_model_1 = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_1))
+        migrated_user_model_2 = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_2))
+        self.assertEqual(
+            migrated_user_model_1.created_on, final_created_on_timestamp_1)
+        self.assertEqual(
+            migrated_user_model_2.created_on, final_created_on_timestamp_2)
+
+    def test_multiple_runs_of_one_off_job_works_correctly(self):
+        user_settings_model_1 = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        user_settings_model_1.update_timestamps()
+        user_settings_model_1.created_on += datetime.timedelta(hours=10)
+        final_created_on_timestamp_1 = user_settings_model_1.last_updated
+        user_settings_model_1.put()
+
+        user_settings_model_2 = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_2,
+                email=self.EMAIL_2,
+            )
+        )
+        user_settings_model_2.update_timestamps()
+        user_settings_model_2.created_on += datetime.timedelta(hours=5)
+        final_created_on_timestamp_2 = user_settings_model_2.last_updated
+        user_settings_model_2.put()
+
+        expected_output = [['SUCCESS_ALREADY_UP_TO_DATE', 2]]
+        self.assertLess(
+            final_created_on_timestamp_1, user_settings_model_1.created_on)
+        self.assertLess(
+            final_created_on_timestamp_2, user_settings_model_2.created_on)
+        actual_output = self._run_one_off_job()
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(actual_output, expected_output)
+        migrated_user_model_1 = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_1))
+        migrated_user_model_2 = (
+            user_models.UserSettingsModel.get_by_id(self.USER_ID_2))
+        self.assertEqual(
+            migrated_user_model_1.created_on, final_created_on_timestamp_1)
+        self.assertEqual(
+            migrated_user_model_2.created_on, final_created_on_timestamp_2)
+
+
+class UserSettingsCreatedOnAuditOneOffJobTests(test_utils.GenericTestBase):
+
+    USER_ID_1 = 'user_id'
+    USER_ID_2 = 'user_id_2'
+    EMAIL_1 = 'test@email.com'
+    EMAIL_2 = 'test2@email.com'
+    SKILL_ID_1 = 'skill_id_1'
+    SKILL_ID_2 = 'skill_id_2'
+    DEGREE_OF_MASTERY = 0.5
+    EXPLORATION_IDS = ['exp_1', 'exp_2', 'exp_3']
+    COLLECTION_IDS = ['col_1', 'col_2', 'col_3']
+    EXP_ID_ONE = 'exp_id_one'
+    EXP_ID_TWO = 'exp_id_two'
+    EXP_ID_THREE = 'exp_id_three'
+
+    def setUp(self):
+
+        def empty(*_):
+            """Function that takes any number of arguments and does nothing."""
+            pass
+
+        # We don't want to sign up the superadmin user.
+        with self.swap(
+            test_utils.AppEngineTestBase, 'signup_superadmin_user', empty):
+            super(UserSettingsCreatedOnAuditOneOffJobTests, self).setUp()
+
+        self.user_settings_model = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_1,
+                email=self.EMAIL_1,
+            )
+        )
+        self.user_settings_model.update_timestamps()
+        self.lowest_timestamp = self.user_settings_model.created_on
+        self.user_settings_model.last_agreed_to_terms = (
+            self.lowest_timestamp + datetime.timedelta(hours=2))
+        self.user_settings_model.last_logged_in = (
+            self.lowest_timestamp + datetime.timedelta(minutes=1))
+        self.user_settings_model.last_started_state_editor_tutorial = (
+            self.lowest_timestamp + datetime.timedelta(minutes=3))
+        self.user_settings_model.last_started_state_translation_tutorial = (
+            self.lowest_timestamp + datetime.timedelta(hours=14))
+        self.user_settings_model.last_edited_an_exploration = (
+            self.lowest_timestamp + datetime.timedelta(hours=15))
+        self.user_settings_model.last_created_an_exploration = (
+            self.lowest_timestamp + datetime.timedelta(hours=16))
+        self.user_settings_model.first_contribution_msec = (
+            utils.get_time_in_millisecs(
+                self.lowest_timestamp + datetime.timedelta(
+                    hours=10)
+            )
+        )
+        self.user_settings_model.put()
+
+        self.user_subscriptions_model = user_models.UserSubscriptionsModel(
+            id=self.USER_ID_1)
+        self.user_subscriptions_model.update_timestamps()
+        self.user_subscriptions_model.last_checked = (
+            self.lowest_timestamp + datetime.timedelta(hours=1)
+        )
+        self.user_subscriptions_model.put()
+
+        self.exploration_user_data_model = user_models.ExplorationUserDataModel(
+            id='%s.%s' % (self.USER_ID_1, self.EXP_ID_ONE),
+            user_id=self.USER_ID_1,
+            exploration_id=self.EXP_ID_ONE,
+            rating=2,
+            rated_on=self.lowest_timestamp + datetime.timedelta(hours=1),
+            draft_change_list={'new_content': {}},
+            draft_change_list_last_updated=(
+                self.lowest_timestamp + datetime.timedelta(hours=2)),
+            draft_change_list_exp_version=3,
+            draft_change_list_id=1
+        )
+        self.exploration_user_data_model.update_timestamps()
+        self.exploration_user_data_model.put()
+
+        self.user_contributions_model = user_models.UserContributionsModel(
+            id=self.USER_ID_1)
+        self.user_contributions_model.update_timestamps()
+        self.user_contributions_model.put()
+
+        self.user_email_preferences_model = (
+            user_models.UserEmailPreferencesModel(id=self.USER_ID_1))
+        self.user_email_preferences_model.update_timestamps()
+        self.user_email_preferences_model.put()
+
+        self.user_stats_model = user_models.UserStatsModel(
+            id=self.USER_ID_1)
+        self.user_stats_model.update_timestamps()
+        self.user_stats_model.put()
+
+    def _run_one_off_job(self):
+        """Runs the one-off MapReduce job."""
+        job_id = (
+            user_jobs_one_off.UserSettingsCreatedOnAuditOneOffJob.create_new())
+        user_jobs_one_off.UserSettingsCreatedOnAuditOneOffJob.enqueue(job_id)
+        self.assertEqual(
+            self.count_jobs_in_mapreduce_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_mapreduce_tasks()
+        stringified_output = (
+            user_jobs_one_off.UserSettingsCreatedOnAuditOneOffJob
+            .get_output(job_id))
+        eval_output = [ast.literal_eval(stringified_item) for
+                       stringified_item in stringified_output]
+        return eval_output
+
+    def test_created_on_having_lowest_value_timestamp_yields_success(self):
+        self.assertEqual(
+            self.lowest_timestamp, self.user_settings_model.created_on)
+        expected_output = [['SUCCESS_ALREADY_UP_TO_DATE', 1]]
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+
+    def test_created_on_within_delta_from_lowest_value_yields_success(self):
+        self.user_settings_model.update_timestamps(
+            update_last_updated_time=False)
+        self.user_settings_model.created_on += datetime.timedelta(minutes=5)
+        self.user_settings_model.put()
+        self.assertLess(
+            self.lowest_timestamp, self.user_settings_model.created_on)
+        expected_output = [['SUCCESS_ALREADY_UP_TO_DATE', 1]]
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+
+    def test_created_on_greater_than_delta_from_lowest_value_yields_error(self):
+        self.user_settings_model.update_timestamps(
+            update_last_updated_time=False)
+        self.user_settings_model.created_on += datetime.timedelta(minutes=6)
+        self.user_settings_model.put()
+        # Since last_updated of user_settings_model was never changed, hence
+        # it remains the lowest timestamp value among all attributes.
+        self.lowest_timestamp = self.user_settings_model.last_updated
+        self.assertLess(
+            self.lowest_timestamp,
+            self.user_settings_model.created_on - datetime.timedelta(minutes=5))
+        expected_output = [
+            [
+                'ERROR_NEED_TO_UPDATE_USING_UserSettingsModel_last_updated',
+                [self.USER_ID_1]
+            ]]
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(expected_output, actual_output)
+
+    def test_update_for_multiple_users_works_correctly(self):
+        user_settings_model_2 = (
+            user_models.UserSettingsModel(
+                id=self.USER_ID_2,
+                email=self.EMAIL_2,
+            )
+        )
+        user_settings_model_2.update_timestamps()
+        user_settings_model_2.created_on += datetime.timedelta(hours=10)
+        user_settings_model_2.put()
+
+        expected_output = [
+            ['SUCCESS_ALREADY_UP_TO_DATE', 1],
+            [
+                'ERROR_NEED_TO_UPDATE_USING_UserSettingsModel_last_updated',
+                [self.USER_ID_2]
+            ]
+        ]
+        actual_output = self._run_one_off_job()
+        self.assertItemsEqual(actual_output, expected_output)
+
+
 class CleanUpUserSubscribersModelOneOffJobTests(test_utils.GenericTestBase):
 
     def setUp(self):