Repository: oppia/oppia

Issue:
Implement backend models for new events
We need to implement backend models to log some of the new events we plan to record for displaying statistical data. The following events need to be implemented:

- [x] AnswerSubmittedEventLogEntryModel

- [x] ExplorationActualStartEventLogEntryModel

- [x] SolutionHitEventLogEntryModel

For details on what attributes/properties these models need to contain, take a look at [design for event models](https://docs.google.com/document/d/11ZAe7BZqYsCS60oIDwvXQ7xKH4wwrrffN3HyqsNFbAw/edit#bookmark=id.oq2z62w0g7ox)


Discussion:

Resolving patch:
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
@@ -59,6 +59,189 @@ def get_or_create(cls, exploration_id, state_name):
         return counter
 
 
+class AnswerSubmittedEventLogEntryModel(base_models.BaseModel):
+    """An event triggered by a student submitting an answer.
+
+    Event schema documentation
+    --------------------------
+    V1:
+        event_type: 'answer_submit'
+        exp_id: id of exploration currently being played
+        exp_version: version of exploration
+        state_name: Name of current state
+        client_time_spent_in_secs: Time since start of this state when this
+            event was recorded
+        session_id: ID of current student's session
+        is_feedback_useful: Whether the answer received useful feedback
+        is_answer_correct: Whether the answer is correct
+    """
+    # Which specific type of event this is
+    event_type = ndb.StringProperty()
+    # Id of exploration currently being played.
+    exp_id = ndb.StringProperty(indexed=True)
+    # Current version of exploration.
+    exp_version = ndb.IntegerProperty(indexed=True)
+    # Name of current state.
+    state_name = ndb.StringProperty(indexed=True)
+    # ID of current student's session
+    session_id = ndb.StringProperty(indexed=True)
+    # Time since start of this state before this event occurred (in sec).
+    client_time_spent_in_secs = ndb.FloatProperty()
+    # Whether the submitted answer received useful feedback
+    is_feedback_useful = ndb.BooleanProperty(indexed=True)
+    # Whether the submitted answer is the correct answer.
+    is_answer_correct = ndb.BooleanProperty(indexed=True)
+
+    @classmethod
+    def get_new_event_entity_id(cls, exp_id, session_id):
+        """Generates a unique id for the event model of the form
+        {{random_hash}} from {{timestamp}:{exp_id}:{session_id}}."""
+        timestamp = datetime.datetime.utcnow()
+        return cls.get_new_id('%s:%s:%s' % (
+            utils.get_time_in_millisecs(timestamp),
+            exp_id,
+            session_id))
+
+    @classmethod
+    def create(cls, exp_id, exp_version, state_name, session_id,
+               client_time_spent_in_secs, is_feedback_useful,
+               is_answer_correct):
+        """Creates a new answer submitted event."""
+        entity_id = cls.get_new_event_entity_id(
+            exp_id, session_id)
+        answer_submitted_event_entity = cls(
+            id=entity_id,
+            event_type=feconf.EVENT_TYPE_ANSWER_SUBMITTED,
+            exp_id=exp_id,
+            exp_version=exp_version,
+            state_name=state_name,
+            session_id=session_id,
+            client_time_spent_in_secs=client_time_spent_in_secs,
+            is_feedback_useful=is_feedback_useful,
+            is_answer_correct=is_answer_correct)
+        answer_submitted_event_entity.put()
+        return entity_id
+
+
+class ExplorationActualStartEventLogEntryModel(base_models.BaseModel):
+    """An event triggered by a student entering an exploration. In this context,
+    'actually' entering an exploration means the student has spent a defined
+    MIN_TIME on the exploration.
+
+    Event schema documentation
+    --------------------------
+    V1:
+        event_type: 'actual_start'
+        exp_id: id of exploration currently being played
+        exp_version: version of exploration
+        state_name: Name of current state
+        client_time_spent_in_secs: Time since start of the exploration when
+            this event was recorded (MIN_TIME to record this event)
+        session_id: ID of current student's session
+    """
+    # Which specific type of event this is
+    event_type = ndb.StringProperty()
+    # Id of exploration currently being played.
+    exp_id = ndb.StringProperty(indexed=True)
+    # Current version of exploration.
+    exp_version = ndb.IntegerProperty(indexed=True)
+    # Name of current state.
+    state_name = ndb.StringProperty(indexed=True)
+    # ID of current student's session
+    session_id = ndb.StringProperty(indexed=True)
+    # Time since start of this state before this event occurred (in sec). This
+    # should be equal to the MIN_TIME defined for recording this event.
+    client_time_spent_in_secs = ndb.FloatProperty()
+
+    @classmethod
+    def get_new_event_entity_id(cls, exp_id, session_id):
+        """Generates a unique id for the event model of the form
+        {{random_hash}} from {{timestamp}:{exp_id}:{session_id}}."""
+        timestamp = datetime.datetime.utcnow()
+        return cls.get_new_id('%s:%s:%s' % (
+            utils.get_time_in_millisecs(timestamp),
+            exp_id,
+            session_id))
+
+    @classmethod
+    def create(cls, exp_id, exp_version, state_name, session_id,
+               client_time_spent_in_secs):
+        """Creates a new actual exploration start event."""
+        entity_id = cls.get_new_event_entity_id(
+            exp_id, session_id)
+        actual_start_event_entity = cls(
+            id=entity_id,
+            event_type=feconf.EVENT_TYPE_ACTUAL_START_EXPLORATION,
+            exp_id=exp_id,
+            exp_version=exp_version,
+            state_name=state_name,
+            session_id=session_id,
+            client_time_spent_in_secs=client_time_spent_in_secs)
+        actual_start_event_entity.put()
+        return entity_id
+
+
+class SolutionHitEventLogEntryModel(base_models.BaseModel):
+    """An event triggered by a student triggering the solution.
+
+    Event schema documentation
+    --------------------------
+    V1:
+        event_type: 'solution'
+        exp_id: id of exploration currently being played
+        exp_version: version of exploration
+        state_name: Name of current state
+        client_time_spent_in_secs: Time since start of this state when this
+            event was recorded
+        session_id: ID of current student's session
+        is_solution_preceding_answer: Whether the solution was triggered before
+            a correct answer was submitted.
+    """
+    # Which specific type of event this is
+    event_type = ndb.StringProperty()
+    # Id of exploration currently being played.
+    exp_id = ndb.StringProperty(indexed=True)
+    # Current version of exploration.
+    exp_version = ndb.IntegerProperty(indexed=True)
+    # Name of current state.
+    state_name = ndb.StringProperty(indexed=True)
+    # ID of current student's session
+    session_id = ndb.StringProperty(indexed=True)
+    # Time since start of this state before this event occurred (in sec).
+    client_time_spent_in_secs = ndb.FloatProperty()
+    # Whether the solution was triggered before the student submitted a correct
+    # answer to the same state.
+    is_solution_preceding_answer = ndb.BooleanProperty(indexed=True)
+
+    @classmethod
+    def get_new_event_entity_id(cls, exp_id, session_id):
+        """Generates a unique id for the event model of the form
+        {{random_hash}} from {{timestamp}:{exp_id}:{session_id}}."""
+        timestamp = datetime.datetime.utcnow()
+        return cls.get_new_id('%s:%s:%s' % (
+            utils.get_time_in_millisecs(timestamp),
+            exp_id,
+            session_id))
+
+    @classmethod
+    def create(cls, exp_id, exp_version, state_name, session_id,
+               client_time_spent_in_secs, is_solution_preceding_answer):
+        """Creates a new answer submitted event."""
+        entity_id = cls.get_new_event_entity_id(
+            exp_id, session_id)
+        solution_hit_event_entity = cls(
+            id=entity_id,
+            event_type=feconf.EVENT_TYPE_SOLUTION,
+            exp_id=exp_id,
+            exp_version=exp_version,
+            state_name=state_name,
+            session_id=session_id,
+            client_time_spent_in_secs=client_time_spent_in_secs,
+            is_solution_preceding_answer=is_solution_preceding_answer)
+        solution_hit_event_entity.put()
+        return entity_id
+
+
 class StartExplorationEventLogEntryModel(base_models.BaseModel):
     """An event triggered by a student starting the exploration.
 
@@ -307,6 +490,7 @@ def create(cls, exp_id, exp_version, state_name, session_id,
             params=params,
             play_type=play_type)
         complete_event_entity.put()
+        return entity_id
 
 
 class RateExplorationEventLogEntryModel(base_models.BaseModel):
@@ -428,6 +612,7 @@ def create(
             params=params,
             play_type=play_type)
         state_event_entity.put()
+        return entity_id
 
 
 class ExplorationAnnotationsModel(base_models.BaseMapReduceBatchResultsModel):
diff --git a/feconf.py b/feconf.py
--- a/feconf.py
+++ b/feconf.py
@@ -601,9 +601,11 @@ def get_empty_ratings():
 EVENT_TYPE_NEW_THREAD_CREATED = 'feedback_thread_created'
 EVENT_TYPE_THREAD_STATUS_CHANGED = 'feedback_thread_status_changed'
 EVENT_TYPE_RATE_EXPLORATION = 'rate_exploration'
+EVENT_TYPE_SOLUTION = 'solution'
 # The values for these event types should be left as-is for backwards
 # compatibility.
 EVENT_TYPE_START_EXPLORATION = 'start'
+EVENT_TYPE_ACTUAL_START_EXPLORATION = 'actual_start'
 EVENT_TYPE_MAYBE_LEAVE_EXPLORATION = 'leave'
 EVENT_TYPE_COMPLETE_EXPLORATION = 'complete'
 


Test patch:
diff --git a/core/storage/statistics/gae_models_test.py b/core/storage/statistics/gae_models_test.py
new file mode 100644
--- /dev/null
+++ b/core/storage/statistics/gae_models_test.py
@@ -0,0 +1,117 @@
+# coding: utf-8
+#
+# Copyright 2017 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for Oppia statistics models."""
+
+from core.platform import models
+from core.tests import test_utils
+import feconf
+
+(stat_models,) = models.Registry.import_models([models.NAMES.statistics])
+
+class AnswerSubmittedEventLogEntryModelUnitTests(test_utils.GenericTestBase):
+    """Test the AnswerSubmittedEventLogEntryModel class."""
+
+    def test_create_and_get_event_models(self):
+        event_id = (
+            stat_models.AnswerSubmittedEventLogEntryModel.create(
+                'exp_id1', 1, 'state_name1', 'session_id1', 0.0, True, False))
+
+        event_model = stat_models.AnswerSubmittedEventLogEntryModel.get(
+            event_id)
+
+        self.assertEqual(event_model.exp_id, 'exp_id1')
+        self.assertEqual(event_model.exp_version, 1)
+        self.assertEqual(event_model.state_name, 'state_name1')
+        self.assertEqual(event_model.session_id, 'session_id1')
+        self.assertEqual(event_model.client_time_spent_in_secs, 0.0)
+        self.assertEqual(event_model.is_feedback_useful, True)
+        self.assertEqual(event_model.is_answer_correct, False)
+
+class ExplorationActualStartEventLogEntryModelUnitTests(
+        test_utils.GenericTestBase):
+    """Test the ExplorationActualStartEventLogEntryModel class."""
+
+    def test_create_and_get_event_models(self):
+        event_id = (
+            stat_models.ExplorationActualStartEventLogEntryModel.create(
+                'exp_id1', 1, 'state_name1', 'session_id1', 0.0))
+
+        event_model = stat_models.ExplorationActualStartEventLogEntryModel.get(
+            event_id)
+
+        self.assertEqual(event_model.exp_id, 'exp_id1')
+        self.assertEqual(event_model.exp_version, 1)
+        self.assertEqual(event_model.state_name, 'state_name1')
+        self.assertEqual(event_model.session_id, 'session_id1')
+        self.assertEqual(event_model.client_time_spent_in_secs, 0.0)
+
+class SolutionHitEventLogEntryModelUnitTests(test_utils.GenericTestBase):
+    """Test the SolutionHitEventLogEntryModel class."""
+
+    def test_create_and_get_event_models(self):
+        event_id = (
+            stat_models.SolutionHitEventLogEntryModel.create(
+                'exp_id1', 1, 'state_name1', 'session_id1', 0.0, True))
+
+        event_model = stat_models.SolutionHitEventLogEntryModel.get(
+            event_id)
+
+        self.assertEqual(event_model.exp_id, 'exp_id1')
+        self.assertEqual(event_model.exp_version, 1)
+        self.assertEqual(event_model.state_name, 'state_name1')
+        self.assertEqual(event_model.session_id, 'session_id1')
+        self.assertEqual(event_model.client_time_spent_in_secs, 0.0)
+        self.assertEqual(event_model.is_solution_preceding_answer, True)
+
+class StateHitEventLogEntryModelUnitTests(test_utils.GenericTestBase):
+    """Test the StateHitEventLogEntryModel class."""
+
+    def test_create_and_get_event_models(self):
+        event_id = (
+            stat_models.StateHitEventLogEntryModel.create(
+                'exp_id1', 1, 'state_name1', 'session_id1', {},
+                feconf.PLAY_TYPE_NORMAL))
+
+        event_model = stat_models.StateHitEventLogEntryModel.get(
+            event_id)
+
+        self.assertEqual(event_model.exploration_id, 'exp_id1')
+        self.assertEqual(event_model.exploration_version, 1)
+        self.assertEqual(event_model.state_name, 'state_name1')
+        self.assertEqual(event_model.session_id, 'session_id1')
+        self.assertEqual(event_model.play_type, feconf.PLAY_TYPE_NORMAL)
+
+class CompleteExplorationEventLogEntryModelUnitTests(
+        test_utils.GenericTestBase):
+    """Test the CompleteExplorationEventLogEntryModel class."""
+
+    def test_create_and_get_event_models(self):
+        event_id = (
+            stat_models.CompleteExplorationEventLogEntryModel.create(
+                'exp_id1', 1, 'state_name1', 'session_id1', 0.0, {},
+                feconf.PLAY_TYPE_NORMAL))
+
+        event_model = stat_models.CompleteExplorationEventLogEntryModel.get(
+            event_id)
+
+        self.assertEqual(event_model.exploration_id, 'exp_id1')
+        self.assertEqual(event_model.exploration_version, 1)
+        self.assertEqual(event_model.state_name, 'state_name1')
+        self.assertEqual(event_model.session_id, 'session_id1')
+        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)