Track grades for Blockstore content, emit tracking logs

This commit is contained in:
Braden MacDonald
2019-09-11 14:15:04 -07:00
parent 17a8d57699
commit 8a2d499dd2
8 changed files with 237 additions and 16 deletions

View File

@@ -4,7 +4,7 @@ from __future__ import absolute_import
import logging
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.keys import CourseKey, LearningContextKey
from six import text_type
from openedx.core.lib.request_utils import COURSE_REGEX
@@ -40,20 +40,55 @@ def course_context_from_course_id(course_id):
"""
Creates a course context from a `course_id`.
For newer parts of the system (i.e. Blockstore-based libraries/courses/etc.)
use context_dict_for_learning_context instead of this method.
Example Returned Context::
{
'course_id': 'org/course/run',
'org_id': 'org'
}
"""
context_dict = context_dict_for_learning_context(course_id)
# Remove the newer 'context_id' field for now in this method so we're not
# adding a new field to the course tracking logs
del context_dict['context_id']
return context_dict
def context_dict_for_learning_context(context_key):
"""
Creates a tracking log context dictionary for the given learning context
key, which may be None, a CourseKey, a content library key, or any other
type of LearningContextKey.
Example Returned Context Dict::
{
'context_id': 'course-v1:org+course+run',
'course_id': 'course-v1:org+course+run',
'org_id': 'org'
}
Example 2::
{
'context_id': 'lib:edX:a-content-library',
'course_id': '',
'org_id': 'edX'
}
"""
if course_id is None:
return {'course_id': '', 'org_id': ''}
# TODO: Make this accept any CourseKey, and serialize it using .to_string
assert isinstance(course_id, CourseKey)
return {
'course_id': text_type(course_id),
'org_id': course_id.org,
context_dict = {
'context_id': text_type(context_key) if context_key else '',
'course_id': '',
'org_id': '',
}
if context_key is not None:
assert isinstance(context_key, LearningContextKey)
if context_key.is_course:
context_dict['course_id'] = text_type(context_key)
if hasattr(context_key, 'org'):
context_dict['org_id'] = context_key.org
return context_dict

View File

@@ -63,6 +63,9 @@ class Command(BaseCommand):
set_event_transaction_type(PROBLEM_SUBMITTED_EVENT_TYPE)
kwargs = {'modified__range': (modified_start, modified_end), 'module_type': 'problem'}
for record in StudentModule.objects.filter(**kwargs):
if not record.course_id.is_course:
# This is not a course, so we don't store subsection grades for it.
continue
task_args = {
"user_id": record.student_id,
"course_id": six.text_type(record.course_id),
@@ -78,6 +81,9 @@ class Command(BaseCommand):
kwargs = {'created_at__range': (modified_start, modified_end)}
for record in Submission.objects.filter(**kwargs):
if not record.student_item.course_id.is_course:
# This is not a course, so ignore it
continue
task_args = {
"user_id": user_by_anonymous_id(record.student_item.student_id).id,
"anonymous_user_id": record.student_item.student_id,

View File

@@ -10,6 +10,7 @@ import ddt
import six
from django.conf import settings
from mock import MagicMock, patch
from opaque_keys.edx.keys import CourseKey
from pytz import utc
from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum
@@ -40,7 +41,7 @@ class TestRecalculateSubsectionGrades(HasCourseWithProblemsMixin, ModuleStoreTes
submission = MagicMock()
submission.student_item = MagicMock(
student_id="anonymousID",
course_id='x/y/z',
course_id=CourseKey.from_string('course-v1:x+y+z'),
item_id='abc',
)
submission.created_at = utc.localize(datetime.strptime('2016-08-23 16:43', DATE_FORMAT))
@@ -55,7 +56,7 @@ class TestRecalculateSubsectionGrades(HasCourseWithProblemsMixin, ModuleStoreTes
def test_csm(self, task_mock, id_mock, csm_mock):
csm_record = MagicMock()
csm_record.student_id = "ID"
csm_record.course_id = "x/y/z"
csm_record.course_id = CourseKey.from_string('course-v1:x+y+z')
csm_record.module_state_key = "abc"
csm_record.modified = utc.localize(datetime.strptime('2016-08-23 16:43', DATE_FORMAT))
csm_mock.objects.filter.return_value = [csm_record]
@@ -67,7 +68,7 @@ class TestRecalculateSubsectionGrades(HasCourseWithProblemsMixin, ModuleStoreTes
self.command.handle(modified_start='2016-08-25 16:42', modified_end='2018-08-25 16:44')
kwargs = {
"user_id": "ID",
"course_id": u'x/y/z',
"course_id": u'course-v1:x+y+z',
"usage_id": u'abc',
"only_if_higher": False,
"expected_modified_time": to_timestamp(utc.localize(datetime.strptime('2016-08-23 16:43', DATE_FORMAT))),

View File

@@ -8,6 +8,7 @@ from logging import getLogger
import six
from django.dispatch import receiver
from opaque_keys.edx.keys import LearningContextKey
from submissions.models import score_reset, score_set
from xblock.scorable import ScorableXBlockMixin, Score
@@ -220,6 +221,9 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum
enqueueing a subsection update operation to occur asynchronously.
"""
events.grade_updated(**kwargs)
context_key = LearningContextKey.from_string(kwargs['course_id'])
if not context_key.is_course:
return # If it's not a course, it has no subsections, so skip the subsection grading update
recalculate_subsection_grade_v3.apply_async(
kwargs=dict(
user_id=kwargs['user_id'],

View File

@@ -6,6 +6,7 @@ import logging
from django.conf import settings
from django.dispatch import receiver
from opaque_keys.edx.keys import LearningContextKey
import lti_provider.outcomes as outcomes
from lms.djangoapps.grades.api import signals as grades_signals
@@ -47,6 +48,13 @@ def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument
course_id = kwargs.get('course_id', None)
usage_id = kwargs.get('usage_id', None)
# Make sure this came from a course because this code only works with courses
if not course_id:
return
context_key = LearningContextKey.from_string(course_id)
if not context_key.is_course:
return # This is a content library or something else...
if None not in (points_earned, points_possible, user_id, course_id):
course_key, usage_key = parse_course_and_usage_keys(course_id, usage_id)
assignments = increment_assignment_versions(course_key, usage_key, user_id)

View File

@@ -3,15 +3,22 @@
Test the Blockstore-based XBlock runtime and content libraries together.
"""
from __future__ import absolute_import, division, print_function, unicode_literals
import json
import unittest
from django.conf import settings
from django.test import TestCase
from organizations.models import Organization
from rest_framework.test import APIClient
from xblock.core import XBlock, Scope
from xblock import fields
from courseware.model_data import get_score
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_libraries.tests.test_content_libraries import (
URL_BLOCK_RENDER_VIEW,
URL_BLOCK_GET_HANDLER_URL,
)
from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.lib import blockstore_api
from student.tests.factories import UserFactory
@@ -40,8 +47,8 @@ class ContentLibraryContentTestMixin(object):
def setUpClass(cls):
super(ContentLibraryContentTestMixin, cls).setUpClass()
# Create a couple students that the tests can use
cls.student_a = UserFactory.create(username="Alice", email="alice@example.com")
cls.student_b = UserFactory.create(username="Bob", email="bob@example.com")
cls.student_a = UserFactory.create(username="Alice", email="alice@example.com", password="edx")
cls.student_b = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
# Create a collection using Blockstore API directly only because there
# is not yet any Studio REST API for doing so:
cls.collection = blockstore_api.create_collection("Content Library Test Collection")
@@ -187,3 +194,69 @@ class ContentLibraryXBlockUserStateTest(ContentLibraryContentTestMixin, TestCase
block_instance2 = block_instance1.runtime.get_block(block_usage_key)
# Now they should be equal, because we've saved and re-loaded instance2:
self.assertEqual(block_instance1.user_str, block_instance2.user_str)
@unittest.skipUnless(settings.ROOT_URLCONF == "lms.urls", "Scores are only used in the LMS")
def test_scores_persisted(self):
"""
Test that a block's emitted scores are cached in StudentModule
In the future if there is a REST API to retrieve individual block's
scores, that should be used instead of checking StudentModule directly.
"""
block_id = library_api.create_library_block(self.library.key, "problem", "scored_problem").usage_key
new_olx = """
<problem display_name="New Multi Choice Question" max_attempts="5">
<multiplechoiceresponse>
<p>This is a normal capa problem. It has "maximum attempts" set to **5**.</p>
<label>Blockstore is designed to store.</label>
<choicegroup type="MultipleChoice">
<choice correct="false">XBlock metadata only</choice>
<choice correct="true">XBlock data/metadata and associated static asset files</choice>
<choice correct="false">Static asset files for XBlocks and courseware</choice>
<choice correct="false">XModule metadata only</choice>
</choicegroup>
</multiplechoiceresponse>
</problem>
""".strip()
library_api.set_library_block_olx(block_id, new_olx)
library_api.publish_changes(self.library.key)
# Now view the problem as Alice:
client = APIClient()
client.login(username=self.student_a.username, password='edx')
student_view_result = client.get(URL_BLOCK_RENDER_VIEW.format(block_key=block_id, view_name='student_view'))
problem_key = "input_{}_2_1".format(block_id)
self.assertIn(problem_key, student_view_result.data["content"])
# And submit a wrong answer:
result = client.get(URL_BLOCK_GET_HANDLER_URL.format(block_key=block_id, handler_name='xmodule_handler'))
problem_check_url = result.data["handler_url"] + 'problem_check'
submit_result = client.post(problem_check_url, data={problem_key: "choice_3"})
self.assertEqual(submit_result.status_code, 200)
submit_data = json.loads(submit_result.content)
self.assertDictContainsSubset({
"current_score": 0,
"total_possible": 1,
"attempts_used": 1,
}, submit_data)
# Now test that the score is also persisted in StudentModule:
# If we add a REST API to get an individual block's score, that should be checked instead of StudentModule.
sm = get_score(self.student_a, block_id)
self.assertEqual(sm.grade, 0)
self.assertEqual(sm.max_grade, 1)
# And submit a correct answer:
submit_result = client.post(problem_check_url, data={problem_key: "choice_1"})
self.assertEqual(submit_result.status_code, 200)
submit_data = json.loads(submit_result.content)
self.assertDictContainsSubset({
"current_score": 1,
"total_possible": 1,
"attempts_used": 2,
}, submit_data)
# Now test that the score is also updated in StudentModule:
# If we add a REST API to get an individual block's score, that should be checked instead of StudentModule.
sm = get_score(self.student_a, block_id)
self.assertEqual(sm.grade, 1)
self.assertEqual(sm.max_grade, 1)

View File

@@ -4,9 +4,14 @@ Common base classes for all new XBlock runtimes.
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from completion import waffle as completion_waffle
import crum
from django.contrib.auth import get_user_model
from django.utils.lru_cache import lru_cache
from eventtracking import tracker
from six.moves.urllib.parse import urljoin # pylint: disable=import-error
import track.contexts
import track.views
from xblock.exceptions import NoSuchServiceError
from xblock.field_data import SplitFieldData
from xblock.fields import Scope
@@ -14,6 +19,7 @@ from xblock.runtime import DictKeyValueStore, KvsFieldData, NullI18nService, Mem
from web_fragments.fragment import Fragment
from courseware.model_data import DjangoKeyValueStore, FieldDataCache
from lms.djangoapps.grades.api import signals as grades_signals
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
from openedx.core.djangoapps.xblock.runtime.blockstore_field_data import BlockstoreFieldData
from openedx.core.djangoapps.xblock.runtime.mixin import LmsBlockMixin
@@ -27,6 +33,17 @@ log = logging.getLogger(__name__)
User = get_user_model()
def make_track_function():
"""
Make a tracking function that logs what happened, for XBlock events.
"""
current_request = crum.get_current_request()
def function(event_type, event):
return track.views.server_track(current_request, event_type, event, page='x_module')
return function
class XBlockRuntime(RuntimeShim, Runtime):
"""
This class manages one or more instantiated XBlocks for a particular user,
@@ -94,8 +111,72 @@ class XBlockRuntime(RuntimeShim, Runtime):
return absolute_url
def publish(self, block, event_type, event_data):
# TODO: publish events properly
log.info("XBlock %s has published a '%s' event.", block.scope_ids.usage_id, event_type)
""" Handle XBlock events like grades and completion """
special_handler = self.get_event_handler(event_type)
if special_handler:
special_handler(block, event_data)
else:
self.log_event_to_tracking_log(block, event_type, event_data)
def get_event_handler(self, event_type):
"""
Return an appropriate function to handle the event.
Returns None if no special processing is required.
"""
if self.user_id is None:
# We don't/cannot currently record grades or completion for anonymous users.
return None
# In the future when/if we support masquerading, need to be careful here not to affect the user's grades
if event_type == 'grade':
return self.handle_grade_event
elif event_type == 'completion':
if completion_waffle.waffle().is_enabled(completion_waffle.ENABLE_COMPLETION_TRACKING):
return self.handle_completion_event
return None
def log_event_to_tracking_log(self, block, event_type, event_data):
"""
Log this XBlock event to the tracking log
"""
log_context = track.contexts.context_dict_for_learning_context(block.scope_ids.usage_id.context_key)
if self.user_id:
log_context['user_id'] = self.user_id
log_context['asides'] = {}
track_function = make_track_function()
with tracker.get_tracker().context(event_type, log_context):
track_function(event_type, event_data)
def handle_grade_event(self, block, event):
"""
Submit a grade for the block.
"""
if not self.user.is_anonymous():
grades_signals.SCORE_PUBLISHED.send(
sender=None,
block=block,
user=self.user,
raw_earned=event['value'],
raw_possible=event['max_value'],
only_if_higher=event.get('only_if_higher'),
score_deleted=event.get('score_deleted'),
grader_response=event.get('grader_response')
)
def handle_completion_event(self, block, event):
"""
Submit a completion object for the block.
"""
block_key = block.scope_ids.usage_id
# edx-completion needs to be updated to support learning contexts, which is coming soon in a separate PR.
# For now just log a debug statement to confirm this plumbing is ready to send those events through.
log.debug("Completion event for block {}: new completion = {}".format(block_key, event['completion']))
# BlockCompletion.objects.submit_completion(
# user=self.user,
# course_key=block_key.context_key,
# block_key=block_key,
# completion=event['completion'],
# )
def applicable_aside_types(self, block):
""" Disable XBlock asides in this runtime """

View File

@@ -317,6 +317,19 @@ class RuntimeShim(object):
result['default_value'] = field.to_json(field.default)
return result
def track_function(self, title, event_info):
"""
Publish an event to the tracking log.
This is deprecated in favor of runtime.publish
See https://git.io/JeGLf and https://git.io/JeGLY for context.
"""
warnings.warn(
"runtime.track_function is deprecated. Use runtime.publish() instead.",
DeprecationWarning, stacklevel=2,
)
self.publish(self._active_block, title, event_info)
@property
def user_location(self):
"""