Repository: oppia/oppia

Issue:
Add helper functions to classifier_services.py (for ClassifierTrainingJobModel)
For the ClassifierTrainingJobModel, classifier_services needs to provided the following methods (+ unit tests).

- [x] get_classifier_training_job_from_model

- [x] get_classifier_training_job_by_id

- [x] _save_classifier_training_job

- [x] _create_classifier_training_job

- [x] update_classifier_training_job

- [x] delete_classifier_training_job

/cc @anmolshkl 


Discussion:

Resolving patch:
diff --git a/core/domain/classifier_domain.py b/core/domain/classifier_domain.py
--- a/core/domain/classifier_domain.py
+++ b/core/domain/classifier_domain.py
@@ -187,7 +187,7 @@ class ClassifierTrainingJob(object):
             generating the classifier.
         exp_id: str. The id of the exploration that contains the state
             for which the classifier will be generated.
-        exp_version_when_created: str. The version of the exploration when
+        exp_version: str. The version of the exploration when
             the training job was generated.
         state_name: str. The name of the state for which the classifier will be
             generated.
@@ -210,7 +210,7 @@ class ClassifierTrainingJob(object):
 
     """
 
-    def __init__(self, job_id, algorithm_id, exp_id, exp_version_when_created,
+    def __init__(self, job_id, algorithm_id, exp_id, exp_version,
                  state_name, status, training_data):
         """Constructs a ClassifierTrainingJob domain object.
 
@@ -220,7 +220,7 @@ def __init__(self, job_id, algorithm_id, exp_id, exp_version_when_created,
             generating the classifier.
         exp_id: str. The id of the exploration id that contains the state
             for which classifier will be generated.
-        exp_version_when_created: str. The version of the exploration when
+        exp_version: str. The version of the exploration when
             the training job was generated.
         state_name: str. The name of the state for which the classifier will be
             generated.
@@ -244,7 +244,7 @@ def __init__(self, job_id, algorithm_id, exp_id, exp_version_when_created,
         self._job_id = job_id
         self._algorithm_id = algorithm_id
         self._exp_id = exp_id
-        self._exp_version_when_created = exp_version_when_created
+        self._exp_version = exp_version
         self._state_name = state_name
         self._status = status
         self._training_data = copy.deepcopy(training_data)
@@ -262,8 +262,8 @@ def exp_id(self):
         return self._exp_id
 
     @property
-    def exp_version_when_created(self):
-        return self._exp_version_when_created
+    def exp_version(self):
+        return self._exp_version
 
     @property
     def state_name(self):
@@ -277,6 +277,16 @@ def status(self):
     def training_data(self):
         return self._training_data
 
+    def update_status(self, status):
+        """Updates the status attribute of the ClassifierTrainingJob domain
+        object.
+
+        Args:
+            status: str. The status of the classifier training job.
+        """
+
+        self._status = status
+
     def to_dict(self):
         """Constructs a dict representation of training job domain object.
 
@@ -288,7 +298,7 @@ def to_dict(self):
             'job_id': self._job_id,
             'algorithm_id': self._algorithm_id,
             'exp_id': self._exp_id,
-            'exp_version_when_created': self._exp_version_when_created,
+            'exp_version': self._exp_version,
             'state_name': self._state_name,
             'status': self._status,
             'training_data': self._training_data
@@ -306,10 +316,10 @@ def validate(self):
             raise utils.ValidationError(
                 'Expected exp_id to be a string, received %s' % self.exp_id)
 
-        if not isinstance(self.exp_version_when_created, int):
+        if not isinstance(self.exp_version, int):
             raise utils.ValidationError(
-                'Expected exp_version_when_created to be an int, received %s' %
-                self.exp_version_when_created)
+                'Expected exp_version to be an int, received %s' %
+                self.exp_version)
 
         if not isinstance(self.state_name, basestring):
             raise utils.ValidationError(
@@ -320,7 +330,7 @@ def validate(self):
             raise utils.ValidationError(
                 'Expected status to be in %s, received %s' %
                 feconf.ALLOWED_TRAINING_JOB_STATUSES,
-                self.exp_version_when_created)
+                self.exp_version)
 
         if not isinstance(self.algorithm_id, basestring):
             raise utils.ValidationError(
diff --git a/core/domain/classifier_services.py b/core/domain/classifier_services.py
--- a/core/domain/classifier_services.py
+++ b/core/domain/classifier_services.py
@@ -169,6 +169,8 @@ def _create_classifier(classifier):
     Args:
         classifier: Domain object for the classifier.
 
+    Returns:
+        classifier_id: str. ID of the classifier.
     """
     classifier_id = classifier_models.ClassifierDataModel.create(
         classifier.id, classifier.exp_id,
@@ -224,3 +226,133 @@ def delete_classifier(classifier_id):
     classifier_data_model = classifier_models.ClassifierDataModel.get(
         classifier_id)
     classifier_data_model.delete()
+
+
+def get_classifier_training_job_from_model(classifier_training_job_model):
+    """Gets a classifier training job domain object from a classifier
+    training job model.
+
+    Args:
+        classifier_training_job_model: ClassifierTrainingJobModel. Classifier
+            training job instance in datastore.
+
+    Returns:
+        classifier_training_job: ClassifierTrainingJob. Domain object for the
+            classifier training job.
+    """
+    return classifier_domain.ClassifierTrainingJob(
+        classifier_training_job_model.id,
+        classifier_training_job_model.algorithm_id,
+        classifier_training_job_model.exp_id,
+        classifier_training_job_model.exp_version,
+        classifier_training_job_model.state_name,
+        classifier_training_job_model.status,
+        classifier_training_job_model.training_data)
+
+def get_classifier_training_job_by_id(job_id):
+    """Gets a classifier training job by a job_id.
+
+    Args:
+        job_id: str. ID of the classifier training job.
+
+    Returns:
+        classifier_training_job: ClassifierTrainingJob. Domain object for the
+            classifier training job.
+
+    Raises:
+        Exception: Entity for class ClassifierTrainingJobModel with id not
+            found.
+    """
+    classifier_training_job_model = (
+        classifier_models.ClassifierTrainingJobModel.get(job_id))
+    classifier_training_job = get_classifier_training_job_from_model(
+        classifier_training_job_model)
+    return classifier_training_job
+
+
+def _create_classifier_training_job(classifier_training_job):
+    """Creates classifier training job model in the datastore given a
+    classifier training job domain object.
+
+    Args:
+        classifier_training_job: ClassifierTrainingJob. Domain object for the
+            classifier training job.
+
+    Returns:
+        job_id: str. ID of the classifier training job.
+    """
+    job_id = classifier_models.ClassifierTrainingJobModel.create(
+        classifier_training_job.algorithm_id, classifier_training_job.exp_id,
+        classifier_training_job.exp_version,
+        classifier_training_job.training_data,
+        classifier_training_job.state_name)
+    return job_id
+
+
+def _update_classifier_training_job(classifier_training_job_model, status):
+    """Updates classifier training job model in the datastore given a
+    classifier training job domain object.
+
+    Args:
+        classifier_training_job_model: ClassifierTrainingJobModel. Classifier
+            training job model instance in datastore.
+        status: The status of the job.
+
+    Note: All of the properties of a classifier training job are immutable,
+        except for status.
+    """
+    classifier_training_job_model.status = status
+    classifier_training_job_model.put()
+
+
+def save_classifier_training_job(algorithm_id, exp_id, exp_version,
+                                 state_name, status, training_data,
+                                 job_id="None"):
+    """Checks for the existence of the model.
+    If the model exists, it is updated using _update_classifier_training_job
+        method.
+    If the model doesn't exist, it is created using
+        _create_classifier_training_job method.
+
+    Args:
+        algorithm_id: str. ID of the algorithm used to generate the model.
+        exp_id: str. ID of the exploration.
+        exp_version: int. The exploration version at the time
+            this training job was created.
+        state_name: str. The name of the state to which the classifier
+            belongs.
+        status: str. The status of the training job (NEW by default).
+        training_data: dict. The data used in training phase.
+        job_id: str. The optional job_id which decides to create/update
+            classifier.
+
+    Returns:
+        job_id: str. ID of the classifier training job.
+    """
+    classifier_training_job_model = (
+        classifier_models.ClassifierTrainingJobModel.get(job_id, False))
+    if classifier_training_job_model is None:
+        classifier_training_job = classifier_domain.ClassifierTrainingJob(
+            'job_id_dummy', algorithm_id, exp_id, exp_version,
+            state_name, status, training_data)
+        classifier_training_job.validate()
+        job_id = _create_classifier_training_job(classifier_training_job)
+    else:
+        classifier_training_job = get_classifier_training_job_from_model(
+            classifier_training_job_model)
+        classifier_training_job.validate()
+        _update_classifier_training_job(classifier_training_job_model,
+                                        status)
+    return job_id
+
+
+def delete_classifier_training_job(job_id):
+    """Deletes classifier training job model in the datastore given job_id.
+
+    Args:
+        job_id: str. ID of the classifier training job.
+    """
+    classifier_training_job_model = (
+        classifier_models.ClassifierTrainingJobModel.get(job_id))
+    if classifier_training_job_model is not None:
+        classifier_training_job_model.delete()
diff --git a/core/storage/classifier/gae_models.py b/core/storage/classifier/gae_models.py
--- a/core/storage/classifier/gae_models.py
+++ b/core/storage/classifier/gae_models.py
@@ -100,7 +100,7 @@ class ClassifierTrainingJobModel(base_models.BaseModel):
     # The exploration_id of the exploration to whose state the model belongs.
     exp_id = ndb.StringProperty(required=True, indexed=True)
     # The exploration version at the time this training job was created.
-    exp_version_when_created = ndb.IntegerProperty(required=True, indexed=True)
+    exp_version = ndb.IntegerProperty(required=True, indexed=True)
     # The name of the state to which the model belongs.
     state_name = ndb.StringProperty(required=True, indexed=True)
     # The status of the training job. It can be either NEW, COMPLETE or PENDING
@@ -144,14 +144,14 @@ def _generate_id(cls, exp_id):
 
     @classmethod
     def create(
-            cls, algorithm_id, exp_id, exp_version_when_created, training_data,
+            cls, algorithm_id, exp_id, exp_version, training_data,
             state_name, status=feconf.TRAINING_JOB_STATUS_NEW):
         """Creates a new ClassifierTrainingJobModel entry.
 
         Args:
             algorithm_id: str. ID of the algorithm used to generate the model.
             exp_id: str. ID of the exploration.
-            exp_version_when_created: int. The exploration version at the time
+            exp_version: int. The exploration version at the time
                 this training job was created.
             state_name: str. The name of the state to which the classifier
                 belongs.
@@ -168,7 +168,7 @@ def create(
         instance_id = cls._generate_id(exp_id)
         training_job_instance = cls(
             id=instance_id, algorithm_id=algorithm_id, exp_id=exp_id,
-            exp_version_when_created=exp_version_when_created,
+            exp_version=exp_version,
             state_name=state_name, status=status, training_data=training_data
             )
 


Test patch:
diff --git a/core/domain/classifier_domain_test.py b/core/domain/classifier_domain_test.py
--- a/core/domain/classifier_domain_test.py
+++ b/core/domain/classifier_domain_test.py
@@ -171,7 +171,7 @@ def _get_training_job_from_dict(self, training_job_dict):
             training_job_dict['job_id'],
             training_job_dict['algorithm_id'],
             training_job_dict['exp_id'],
-            training_job_dict['exp_version_when_created'],
+            training_job_dict['exp_version'],
             training_job_dict['state_name'],
             training_job_dict['status'],
             training_job_dict['training_data'])
@@ -183,7 +183,7 @@ def test_to_dict(self):
             'job_id': 'exp_id1.SOME_RANDOM_STRING',
             'algorithm_id': 'LDAStringClassifier',
             'exp_id': 'exp_id1',
-            'exp_version_when_created': 1,
+            'exp_version': 1,
             'state_name': 'a state name',
             'status': 'NEW',
             'training_data': [
@@ -219,7 +219,7 @@ def test_validation(self):
         training_job_dict = {
             'job_id': 'exp_id1.SOME_RANDOM_STRING',
             'exp_id': 'exp_id1',
-            'exp_version_when_created': 1,
+            'exp_version': 1,
             'state_name': 'some state',
             'algorithm_id': 'LDAStringClassifier',
             'training_data': training_data,
@@ -237,16 +237,16 @@ def test_validation(self):
             training_job.validate()
 
         # Verify validation error is raised when string is provided for
-        # exp_version_when_created instead of int.
+        # exp_version instead of int.
         training_job_dict['job_id'] = 'exp_id1.SOME_RANDOM_STRING'
-        training_job_dict['exp_version_when_created'] = 'abc'
+        training_job_dict['exp_version'] = 'abc'
         training_job = self._get_training_job_from_dict(training_job_dict)
         with self.assertRaisesRegexp(utils.ValidationError, (
-            'Expected exp_version_when_created to be an int')):
+            'Expected exp_version to be an int')):
             training_job.validate()
 
         # Verify validation error is raised when invalid state_name is provided.
-        training_job_dict['exp_version_when_created'] = 1
+        training_job_dict['exp_version'] = 1
         training_job_dict['state_name'] = 'A string #'
         training_job = self._get_training_job_from_dict(training_job_dict)
         with self.assertRaisesRegexp(utils.ValidationError, (
diff --git a/core/domain/classifier_services_test.py b/core/domain/classifier_services_test.py
--- a/core/domain/classifier_services_test.py
+++ b/core/domain/classifier_services_test.py
@@ -173,3 +173,83 @@ def test_update_of_classifiers(self):
         self.assertEqual(classifier.exp_id, exp_id)
         self.assertEqual(classifier.state_name, test_state_name)
         self.assertEqual(classifier.id, classifier_id)
+
+    def test_retrieval_of_classifier_training_jobs(self):
+        """Test the get_classifier_training_job_by_id method."""
+
+        with self.assertRaisesRegexp(Exception, (
+            'Entity for class ClassifierTrainingJobModel with id fake_id '
+            'not found')):
+            classifier_services.get_classifier_training_job_by_id('fake_id')
+
+        exp_id = u'1'
+        state_name = 'Home'
+        job_id = classifier_models.ClassifierTrainingJobModel.create(
+            feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput']['algorithm_id'],
+            exp_id, 1, [], state_name)
+        classifier_training_job = (
+            classifier_services.get_classifier_training_job_by_id(job_id))
+        self.assertEqual(classifier_training_job.algorithm_id,
+                         feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput'][
+                             'algorithm_id'])
+        self.assertEqual(classifier_training_job.exp_id, exp_id)
+        self.assertEqual(classifier_training_job.exp_version, 1)
+        self.assertEqual(classifier_training_job.training_data, [])
+        self.assertEqual(classifier_training_job.state_name, state_name)
+        self.assertEqual(classifier_training_job.status, 'NEW')
+
+    def test_deletion_of_classifier_training_jobs(self):
+        """Test the delete_classifier_training_job method."""
+
+        exp_id = u'1'
+        state_name = 'Home'
+        job_id = classifier_models.ClassifierTrainingJobModel.create(
+            feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput']['algorithm_id'],
+            exp_id, 1, [], state_name)
+        self.assertTrue(job_id)
+        classifier_services.delete_classifier_training_job(job_id)
+        with self.assertRaisesRegexp(Exception, (
+            'Entity for class ClassifierTrainingJobModel '
+            'with id %s not found' %(
+                job_id))):
+            classifier_services.get_classifier_training_job_by_id(job_id)
+
+    def test_update_of_classifier_training_jobs(self):
+        """Test the save_classifier_training_job method."""
+
+        exp_id = u'1'
+        state_name = 'Home'
+        status = 'NEW'
+        test_status = 'PENDING'
+        training_data = [
+            {
+                'answer_group_index': 1,
+                'answers': ['a1', 'a2']
+            },
+            {
+                'answer_group_index': 2,
+                'answers': ['a2', 'a3']
+            }
+        ]
+        # Job does not exist yet.
+        job_id = classifier_services.save_classifier_training_job(
+            feconf.INTERACTION_CLASSIFIER_MAPPING['TextInput']['algorithm_id'],
+            exp_id, 1, state_name, status, training_data)
+        classifier_training_job = (
+            classifier_services.get_classifier_training_job_by_id(job_id))
+        self.assertEqual(classifier_training_job.exp_id, exp_id)
+        self.assertEqual(classifier_training_job.status, status)
+        classifier_training_job.update_status(test_status)
+        # Updating existing job.
+        classifier_services.save_classifier_training_job(
+            classifier_training_job.algorithm_id,
+            classifier_training_job.exp_id,
+            classifier_training_job.exp_version,
+            classifier_training_job.state_name,
+            classifier_training_job.status,
+            classifier_training_job.training_data,
+            classifier_training_job.job_id)
+        classifier_training_job = (
+            classifier_services.get_classifier_training_job_by_id(job_id))
+        self.assertEqual(classifier_training_job.exp_id, exp_id)
+        self.assertEqual(classifier_training_job.status, test_status)
diff --git a/core/storage/classifier/gae_models_test.py b/core/storage/classifier/gae_models_test.py
--- a/core/storage/classifier/gae_models_test.py
+++ b/core/storage/classifier/gae_models_test.py
@@ -84,7 +84,7 @@ def test_create_and_get_new_training_job_runs_successfully(self):
 
         self.assertEqual(training_job.algorithm_id, 'LDAStringClassifier')
         self.assertEqual(training_job.exp_id, 'exp_id1')
-        self.assertEqual(training_job.exp_version_when_created, 1)
+        self.assertEqual(training_job.exp_version, 1)
         self.assertEqual(training_job.state_name, 'state_name2')
         self.assertEqual(training_job.status,
                          feconf.TRAINING_JOB_STATUS_NEW)