Repository: oppia/oppia

Issue:
Implement storage layer and domain layer classes for ExplorationStatsModel along with the domain layer for StateStats. 
We need to implement the backend models for ExplorationStatsModel and the domain class for StateStats. 

**The schema for the ExplorationStatsModel:**

Keyed by (exp_id, exp_version).
The analytics model is common for all the event types for exploration wide statistics. The suggested design:
1. Exp_id. Str. ID of the exploration
2. Exp_version: int. Version of the exploration
3. Num_actual_starts: int. Number of learners who actually attempted the exploration
4. Num_completions: int. Number of learners who completed the exploration
5. State_stats: dict. A dict mapping each state name to its corresponding StateStats domain object.

**The schema for the StateStats domain class:**

1. State_name: str. Name of the state.
2. Total_answers_count: int. Total number of answers submitted for the state.
3. Useful_feedback_count: int. Number of answers that received useful feedback.
4. Learners_answered_correctly: int. Number of learners who submitted correct answers to the state.
5. Total_hit_count: int. Number of times the state was entered.
6. First_hit_count: int. Number of times the state was entered for the first time in a session.
7. Total_solutions_triggered_count: int. Number of times the solution button was triggered to answer a state.




Discussion:

Resolving patch:
diff --git a/core/domain/stats_domain.py b/core/domain/stats_domain.py
--- a/core/domain/stats_domain.py
+++ b/core/domain/stats_domain.py
@@ -41,6 +41,157 @@
 MIGRATED_STATE_ANSWER_TIME_SPENT_IN_SEC = 0.0
 
 
+class ExplorationStats(object):
+    """Domain object representing analytics data for an exploration."""
+
+    def __init__(
+            self, exp_id, exp_version, num_actual_starts, num_completions,
+            state_stats_mapping):
+        """Constructs an ExplorationStats domain object.
+
+        Args:
+            exp_id: str. ID of the exploration.
+            exp_version: int. Version of the exploration.
+            num_actual_starts: int. Number of learners who actually attempted
+                the exploration. Theses are the learners who spent some
+                minimum fixed time on the exploration.
+            num_completions: int. Number of learners who completed the
+                exploration.
+            state_stats_mapping: dict. A dictionary mapping the state names of
+                an exploration to the corresponding StateStats domain object.
+        """
+        self.exp_id = exp_id
+        self.exp_version = exp_version
+        self.num_actual_starts = num_actual_starts
+        self.num_completions = num_completions
+        self.state_stats_mapping = state_stats_mapping
+
+    def to_dict(self):
+        """Returns a dict representation of the domain object."""
+        exploration_stats_dict = {
+            'exp_id': self.exp_id,
+            'exp_version': self.exp_version,
+            'num_actual_starts': self.num_actual_starts,
+            'num_completions': self.num_completions,
+            'state_stats_mapping': self.state_stats_mapping
+        }
+        return exploration_stats_dict
+
+    def validate(self):
+        """Validates the ExplorationStats domain object."""
+
+        if not isinstance(self.exp_id, basestring):
+            raise utils.ValidationError(
+                'Expected exp_id to be a string, received %s' % (self.exp_id))
+
+        if not isinstance(self.exp_version, int):
+            raise utils.ValidationError(
+                'Expected exp_version to be an int, received %s' % (
+                    self.exp_version))
+
+        if not isinstance(self.num_actual_starts, int):
+            raise utils.ValidationError(
+                'Expected num_actual_starts to be an int, received %s' % (
+                    self.num_actual_starts))
+
+        if self.num_actual_starts < 0:
+            raise utils.ValidationError(
+                '%s cannot have negative values' % ('num_actual_starts'))
+
+        if not isinstance(self.num_completions, int):
+            raise utils.ValidationError(
+                'Expected num_completions to be an int, received %s' % (
+                    self.num_completions))
+
+        if self.num_completions < 0:
+            raise utils.ValidationError(
+                '%s cannot have negative values' % ('num_completions'))
+
+        if not isinstance(self.state_stats_mapping, dict):
+            raise utils.ValidationError(
+                'Expected state_stats_mapping to be a dict, received %s' % (
+                    self.state_stats_mapping))
+
+
+class StateStats(object):
+    """Domain object representing analytics data for an exploration's state."""
+
+    def __init__(
+            self, total_answers_count, useful_feedback_count,
+            learners_answered_correctly, total_hit_count, first_hit_count,
+            total_solutions_triggered_count):
+        """Constructs a StateStats domain object.
+
+        Args:
+            total_answers_count: int. Total number of answers submitted to this
+                state.
+            useful_feedback_count: int. Total number of answers that received
+                useful feedback.
+            learners_answered_correctly: int. Number of learners who submitted
+                correct answers to the state.
+            total_hit_count: int. Total number of times the state was entered.
+            first_hit_count: int. Number of times the state was entered for the
+                first time.
+            total_solutions_triggered_count: int. Number of times the solution
+                button was triggered to answer a state.
+        """
+        self.total_answers_count = total_answers_count
+        self.useful_feedback_count = useful_feedback_count
+        self.learners_answered_correctly = learners_answered_correctly
+        self.total_hit_count = total_hit_count
+        self.first_hit_count = first_hit_count
+        self.total_solutions_triggered_count = total_solutions_triggered_count
+
+    def to_dict(self):
+        """Returns a dict representation of the domain oject."""
+        state_stats_dict = {
+            'total_answers_count': self.total_answers_count,
+            'useful_feedback_count': self.useful_feedback_count,
+            'learners_answered_correctly': self.learners_answered_correctly,
+            'total_hit_count': self.total_hit_count,
+            'first_hit_count': self.first_hit_count,
+            'total_solutions_triggered_count': (
+                self.total_solutions_triggered_count)
+        }
+        return state_stats_dict
+
+    @classmethod
+    def from_dict(cls, state_stats_dict):
+        """Constructs a StateStats domain object from a dict."""
+        return cls(
+            state_stats_dict['total_answers_count'],
+            state_stats_dict['useful_feedback_count'],
+            state_stats_dict['learners_answered_correctly'],
+            state_stats_dict['total_hit_count'],
+            state_stats_dict['first_hit_count'],
+            state_stats_dict['total_solutions_triggered_count']
+        )
+
+    def validate(self):
+        """Validates the StateStats domain object."""
+
+        state_stats_properties = [
+            'total_answers_count',
+            'useful_feedback_count',
+            'learners_answered_correctly',
+            'total_hit_count',
+            'first_hit_count',
+            'total_solutions_triggered_count'
+        ]
+
+        state_stats_dict = self.to_dict()
+
+        for stat_property in state_stats_properties:
+            if not isinstance(state_stats_dict[stat_property], int):
+                raise utils.ValidationError(
+                    'Expected %s to be an int, received %s' % (
+                        stat_property, state_stats_dict[stat_property]))
+
+            if state_stats_dict[stat_property] < 0:
+                raise utils.ValidationError(
+                    '%s cannot have negative values' % (stat_property))
+
+
 # TODO(bhenning): Monitor sizes (lengths of submitted_answer_list) of these
 # objects and determine if we should enforce an upper bound for
 # submitted_answer_list.
diff --git a/core/storage/statistics/gae_models.py b/core/storage/statistics/gae_models.py
--- a/core/storage/statistics/gae_models.py
+++ b/core/storage/statistics/gae_models.py
@@ -615,6 +615,75 @@ def create(
         return entity_id
 
 
+class ExplorationStatsModel(base_models.BaseModel):
+    """Model for storing analytics data for an exploration.
+
+    The ID of instances of this class has the form {{exp_id}}.{{exp_version}}.
+    """
+    # ID of exploration.
+    exp_id = ndb.StringProperty(indexed=True)
+    # Version of exploration.
+    exp_version = ndb.IntegerProperty(indexed=True)
+    # Number of students who actually attempted the exploration. Only learners
+    # who spent a minimum time on the exploration are considered to have
+    # actually started the exploration.
+    num_actual_starts = ndb.IntegerProperty(indexed=False)
+    # Number of students who completed the exploration.
+    num_completions = ndb.IntegerProperty(indexed=False)
+    # Keyed by state name that describes the analytics for that state.
+    # {state_name: {
+    #   'total_answers_count': ...,
+    #   'useful_feedback_count': ...,
+    #   'learners_answered_correctly': ...,
+    #   'total_hit_count': ...,
+    #   'first_hit_count': ...,
+    #   'total_solutions_triggered_count': ...}}
+    state_stats_mapping = ndb.JsonProperty(indexed=False)
+
+    @classmethod
+    def get_entity_id(cls, exp_id, exp_version):
+        """Generates an ID for the instance of the form
+        {{exp_id}}.{{exp_version}}.
+
+        Args:
+            exp_id: str. ID of the exploration.
+            exp_version: int. Version of the exploration.
+
+        Returns:
+            str. ID of the new ExplorationStatsModel instance.
+        """
+        return '%s.%s' % (exp_id, exp_version)
+
+    @classmethod
+    def create(
+            cls, exp_id, exp_version, num_actual_starts, num_completions,
+            state_stats_mapping):
+        """Creates an ExplorationStatsModel instance and writes it to the
+        datastore.
+
+        Args:
+            exp_id: str. ID of the exploration.
+            exp_version: int. Version of the exploration.
+            num_actual_starts: int. Number of learners who attempted the
+                exploration.
+            num_completions: int. Number of learners who completed the
+                exploration.
+            state_stats_mapping: dict. Mapping from state names to state stats
+                dicts.
+
+        Returns:
+            str. ID of the new ExplorationStatsModel instance.
+        """
+        instance_id = cls.get_entity_id(exp_id, exp_version)
+        stats_instance = cls(
+            id=instance_id, exp_id=exp_id, exp_version=exp_version,
+            num_actual_starts=num_actual_starts,
+            num_completions=num_completions,
+            state_stats_mapping=state_stats_mapping)
+        stats_instance.put()
+        return instance_id
+
+
 class ExplorationAnnotationsModel(base_models.BaseMapReduceBatchResultsModel):
     """Batch model for storing MapReduce calculation output for
     exploration-level statistics.


Test patch:
diff --git a/core/domain/stats_domain_test.py b/core/domain/stats_domain_test.py
--- a/core/domain/stats_domain_test.py
+++ b/core/domain/stats_domain_test.py
@@ -18,6 +18,137 @@
 from core.domain import stats_domain
 from core.tests import test_utils
 import feconf
+import utils
+
+
+class ExplorationStatsTests(test_utils.GenericTestBase):
+    """Tests the ExplorationStats domain object."""
+
+    def _get_exploration_stats_from_dict(self, exploration_stats_dict):
+        return stats_domain.ExplorationStats(
+            exploration_stats_dict['exp_id'],
+            exploration_stats_dict['exp_version'],
+            exploration_stats_dict['num_actual_starts'],
+            exploration_stats_dict['num_completions'],
+            exploration_stats_dict['state_stats_mapping'])
+
+    def test_to_dict(self):
+        expected_exploration_stats_dict = {
+            'exp_id': 'exp_id1',
+            'exp_version': 1,
+            'num_actual_starts': 10,
+            'num_completions': 5,
+            'state_stats_mapping': {
+                'Home': {}
+            }
+        }
+        observed_exploration_stats = self._get_exploration_stats_from_dict(
+            expected_exploration_stats_dict)
+        self.assertDictEqual(
+            expected_exploration_stats_dict,
+            observed_exploration_stats.to_dict())
+
+    def test_validate(self):
+        exploration_stats_dict = {
+            'exp_id': 'exp_id1',
+            'exp_version': 1,
+            'num_actual_starts': 10,
+            'num_completions': 5,
+            'state_stats_mapping': {
+                'Home': {}
+            }
+        }
+        exploration_stats = self._get_exploration_stats_from_dict(
+            exploration_stats_dict)
+        exploration_stats.validate()
+
+        # Make the exp_id integer.
+        exploration_stats.exp_id = 10
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            'Expected exp_id to be a string')):
+            exploration_stats.validate()
+
+        # Make the num_actual_starts string.
+        exploration_stats.exp_id = 'exp_id1'
+        exploration_stats.num_actual_starts = '0'
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            'Expected num_actual_starts to be an int')):
+            exploration_stats.validate()
+
+        # Make the state_stats_mapping list.
+        exploration_stats.num_actual_starts = 10
+        exploration_stats.state_stats_mapping = []
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            'Expected state_stats_mapping to be a dict')):
+            exploration_stats.validate()
+
+        # Make the num_completions negative.
+        exploration_stats.state_stats_mapping = {}
+        exploration_stats.num_completions = -5
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            '%s cannot have negative values' % ('num_completions'))):
+            exploration_stats.validate()
+
+
+class StateStatsTests(test_utils.GenericTestBase):
+    """Tests the StateStats domain object."""
+
+    def test_from_dict(self):
+        state_stats_dict = {
+            'total_answers_count': 10,
+            'useful_feedback_count': 4,
+            'learners_answered_correctly': 3,
+            'total_hit_count': 18,
+            'first_hit_count': 7,
+            'total_solutions_triggered_count': 2
+        }
+        state_stats = stats_domain.StateStats(10, 4, 3, 18, 7, 2)
+        expected_state_stats = stats_domain.StateStats.from_dict(
+            state_stats_dict)
+        self.assertEqual(
+            state_stats.total_answers_count,
+            expected_state_stats.total_answers_count)
+        self.assertEqual(
+            state_stats.useful_feedback_count,
+            expected_state_stats.useful_feedback_count)
+        self.assertEqual(
+            state_stats.learners_answered_correctly,
+            expected_state_stats.learners_answered_correctly)
+        self.assertEqual(
+            state_stats.total_hit_count, expected_state_stats.total_hit_count)
+        self.assertEqual(
+            state_stats.first_hit_count, expected_state_stats.first_hit_count)
+        self.assertEqual(
+            state_stats.total_solutions_triggered_count,
+            expected_state_stats.total_solutions_triggered_count)
+
+    def test_to_dict(self):
+        state_stats_dict = {
+            'total_answers_count': 10,
+            'useful_feedback_count': 4,
+            'learners_answered_correctly': 3,
+            'total_hit_count': 18,
+            'first_hit_count': 7,
+            'total_solutions_triggered_count': 2
+        }
+        state_stats = stats_domain.StateStats(10, 4, 3, 18, 7, 2)
+        self.assertEqual(state_stats_dict, state_stats.to_dict())
+
+    def test_validation(self):
+        state_stats = stats_domain.StateStats(10, 4, 3, 18, 7, 2)
+        state_stats.validate()
+
+        # Change total_answers_count to string.
+        state_stats.total_answers_count = '10'
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            'Expected total_answers_count to be an int')):
+            state_stats.validate()
+
+        # Make the total_answers_count negative.
+        state_stats.total_answers_count = -5
+        with self.assertRaisesRegexp(utils.ValidationError, (
+            '%s cannot have negative values' % ('total_answers_count'))):
+            state_stats.validate()
 
 
 class StateAnswersTests(test_utils.GenericTestBase):
diff --git a/core/storage/statistics/gae_models_test.py b/core/storage/statistics/gae_models_test.py
--- a/core/storage/statistics/gae_models_test.py
+++ b/core/storage/statistics/gae_models_test.py
@@ -115,3 +115,19 @@ def test_create_and_get_event_models(self):
         self.assertEqual(event_model.client_time_spent_in_secs, 0.0)
         self.assertEqual(event_model.params, {})
         self.assertEqual(event_model.play_type, feconf.PLAY_TYPE_NORMAL)
+
+class ExplorationStatsModelUnitTests(test_utils.GenericTestBase):
+    """Test the ExplorationStatsModel class."""
+
+    def test_create_and_get_analytics_model(self):
+        model_id = (
+            stat_models.ExplorationStatsModel.create('exp_id1', 1, 0, 0, {}))
+
+        model = stat_models.ExplorationStatsModel.get(model_id)
+
+        self.assertEqual(model.id, model_id)
+        self.assertEqual(model.exp_id, 'exp_id1')
+        self.assertEqual(model.exp_version, 1)
+        self.assertEqual(model.num_actual_starts, 0)
+        self.assertEqual(model.num_completions, 0)
+        self.assertEqual(model.state_stats_mapping, {})