Track grades for Blockstore content, emit tracking logs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user