Commit aa374ca1 by Calen Pennington

Make DjangoXBlockUserStateClient pass semantic tests

This required the following changes to the DjangoXBlockUserStateClient
semantics:

1) Changes get/get_many to return XBlockUserState tuples, rather
than state dictionaries or (block_key, state) tuples.
2) Raises DoesNotExist if get_history is called on an XBlock that has
had no data saved to it.
3) Returns XBlockUserState tuples as the results of get_history.
parent 53b37e74
...@@ -76,16 +76,19 @@ class Command(BaseCommand): ...@@ -76,16 +76,19 @@ class Command(BaseCommand):
user_state_client = DjangoXBlockUserStateClient() user_state_client = DjangoXBlockUserStateClient()
hist_modules = user_state_client.get_history(module.student.username, module.module_state_key) hist_modules = user_state_client.get_history(module.student.username, module.module_state_key)
for hist_module in hist_modules: try:
self.remove_studentmodulehistory_input_state(hist_module, save_changes) for hist_module in hist_modules:
self.remove_studentmodulehistory_input_state(hist_module, save_changes)
if self.num_visited % 1000 == 0:
LOG.info(" Progress: updated %s of %s student modules", self.num_changed, self.num_visited) if self.num_visited % 1000 == 0:
LOG.info( LOG.info(" Progress: updated %s of %s student modules", self.num_changed, self.num_visited)
" Progress: updated %s of %s student history modules", LOG.info(
self.num_hist_changed, " Progress: updated %s of %s student history modules",
self.num_hist_visited self.num_hist_changed,
) self.num_hist_visited
)
except DjangoXBlockUserStateClient.DoesNotExist:
LOG.info("No history entries found for %s", module.module_state_key)
@transaction.autocommit @transaction.autocommit
def remove_studentmodule_input_state(self, module, save_changes): def remove_studentmodule_input_state(self, module, save_changes):
......
...@@ -368,8 +368,8 @@ class UserStateCache(object): ...@@ -368,8 +368,8 @@ class UserStateCache(object):
self.user.username, self.user.username,
_all_usage_keys(xblocks, aside_types), _all_usage_keys(xblocks, aside_types),
) )
for usage_key, field_state in block_field_state: for user_state in block_field_state:
self._cache[usage_key] = field_state self._cache[user_state.block_key] = user_state.state
@contract(kvs_key=DjangoKeyValueStore.Key) @contract(kvs_key=DjangoKeyValueStore.Key)
def set(self, kvs_key, value): def set(self, kvs_key, value):
...@@ -392,11 +392,14 @@ class UserStateCache(object): ...@@ -392,11 +392,14 @@ class UserStateCache(object):
Returns: datetime if there was a modified date, or None otherwise Returns: datetime if there was a modified date, or None otherwise
""" """
return self._client.get( try:
self.user.username, return self._client.get(
kvs_key.block_scope_id, self.user.username,
fields=[kvs_key.field_name], kvs_key.block_scope_id,
).updated fields=[kvs_key.field_name],
).updated
except self._client.DoesNotExist:
return None
@contract(kv_dict="dict(DjangoKeyValueStore_Key: *)") @contract(kv_dict="dict(DjangoKeyValueStore_Key: *)")
def set_many(self, kv_dict): def set_many(self, kv_dict):
......
...@@ -13,7 +13,7 @@ except ImportError: ...@@ -13,7 +13,7 @@ except ImportError:
from django.contrib.auth.models import User from django.contrib.auth.models import User
from xblock.fields import Scope, ScopeBase from xblock.fields import Scope, ScopeBase
from edx_user_state_client.interface import XBlockUserStateClient from edx_user_state_client.interface import XBlockUserStateClient, XBlockUserState
from courseware.models import StudentModule, StudentModuleHistory from courseware.models import StudentModule, StudentModuleHistory
from contracts import contract, new_contract from contracts import contract, new_contract
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
...@@ -24,6 +24,21 @@ new_contract('UsageKey', UsageKey) ...@@ -24,6 +24,21 @@ new_contract('UsageKey', UsageKey)
class DjangoXBlockUserStateClient(XBlockUserStateClient): class DjangoXBlockUserStateClient(XBlockUserStateClient):
""" """
An interface that uses the Django ORM StudentModule as a backend. An interface that uses the Django ORM StudentModule as a backend.
A note on the format of state storage:
The state for an xblock is stored as a serialized JSON dictionary. The model
field that it is stored in can also take on a value of ``None``. To preserve
existing analytic uses, we will preserve the following semantics:
A state of ``None`` means that the user hasn't ever looked at the xblock.
A state of ``"{}"`` means that the XBlock has at some point stored state for
the current user, but that that state has been deleted.
Otherwise, the dictionary contains all data stored for the user.
None of these conditions should violate the semantics imposed by
XBlockUserStateClient (for instance, once all fields have been deleted from
an XBlock for a user, the state will be listed as ``None`` by :meth:`get_history`,
even though the actual stored state in the database will be ``"{}"``).
""" """
class ServiceUnavailable(XBlockUserStateClient.ServiceUnavailable): class ServiceUnavailable(XBlockUserStateClient.ServiceUnavailable):
...@@ -53,26 +68,6 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -53,26 +68,6 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
""" """
self.user = user self.user = user
def get_mod_date(self, username, block_key, scope=Scope.user_state, fields=None):
"""
Get the last modification date for fields from the specified blocks.
Arguments:
username: The name of the user whose state should be deleted
block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve.
scope (Scope): The scope to retrieve from.
fields: A list of fields to query. If None, delete all stored fields.
Specific implementations are free to return the same modification date
for all fields, if they don't store changes individually per field.
Implementations may omit fields for which data has not been stored.
Returns: list a dict of {field_name: modified_date} for each selected field.
"""
results = self.get_mod_date_many(username, [block_key], scope, fields=fields)
return {
field: date for (_, field, date) in results
}
def _get_student_modules(self, username, block_keys): def _get_student_modules(self, username, block_keys):
""" """
Retrieve the :class:`~StudentModule`s for the supplied ``username`` and ``block_keys``. Retrieve the :class:`~StudentModule`s for the supplied ``username`` and ``block_keys``.
...@@ -101,7 +96,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -101,7 +96,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
def get_many(self, username, block_keys, scope=Scope.user_state, fields=None): def get_many(self, username, block_keys, scope=Scope.user_state, fields=None):
""" """
Retrieve the stored XBlock state for a single xblock usage. Retrieve the stored XBlock state for the specified XBlock usages.
Arguments: Arguments:
username: The name of the user whose state should be retrieved username: The name of the user whose state should be retrieved
...@@ -119,10 +114,22 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -119,10 +114,22 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
modules = self._get_student_modules(username, block_keys) modules = self._get_student_modules(username, block_keys)
for module, usage_key in modules: for module, usage_key in modules:
if module.state is None: if module.state is None:
state = {} continue
else:
state = json.loads(module.state) state = json.loads(module.state)
yield (usage_key, state)
# If the state is the empty dict, then it has been deleted, and so
# conformant UserStateClients should treat it as if it doesn't exist.
if state == {}:
continue
if fields is not None:
state = {
field: state[field]
for field in fields
if field in state
}
yield XBlockUserState(username, usage_key, state, module.modified, scope)
def set_many(self, username, block_keys_to_state, scope=Scope.user_state): def set_many(self, username, block_keys_to_state, scope=Scope.user_state):
""" """
...@@ -143,7 +150,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -143,7 +150,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
# that were queried in get_many) so that if the score has # that were queried in get_many) so that if the score has
# been changed by some other piece of the code, we don't overwrite # been changed by some other piece of the code, we don't overwrite
# that score. # that score.
if self.user.username == username: if self.user is not None and self.user.username == username:
user = self.user user = self.user
else: else:
user = User.objects.get(username=username) user = User.objects.get(username=username)
...@@ -185,7 +192,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -185,7 +192,7 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
student_modules = self._get_student_modules(username, block_keys) student_modules = self._get_student_modules(username, block_keys)
for student_module, _ in student_modules: for student_module, _ in student_modules:
if fields is None: if fields is None:
student_module.state = "{}" student_module.state = None
else: else:
current_state = json.loads(student_module.state) current_state = json.loads(student_module.state)
for field in fields: for field in fields:
...@@ -193,44 +200,25 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -193,44 +200,25 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
del current_state[field] del current_state[field]
student_module.state = json.dumps(current_state) student_module.state = json.dumps(current_state)
# We just read this object, so we know that we can do an update # We just read this object, so we know that we can do an update
student_module.save(force_update=True) student_module.save(force_update=True)
def get_mod_date_many(self, username, block_keys, scope=Scope.user_state, fields=None):
"""
Get the last modification date for fields from the specified blocks.
Arguments:
username: The name of the user whose state should be deleted
block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve.
scope (Scope): The scope to retrieve from.
fields: A list of fields to query. If None, delete all stored fields.
Specific implementations are free to return the same modification date
for all fields, if they don't store changes individually per field.
Implementations may omit fields for which data has not been stored.
Yields: tuples of (block, field_name, modified_date) for each selected field.
"""
if scope != Scope.user_state:
raise ValueError("Only Scope.user_state is supported")
student_modules = self._get_student_modules(username, block_keys)
for student_module, usage_key in student_modules:
if student_module.state is None:
continue
for field in json.loads(student_module.state):
yield (usage_key, field, student_module.modified)
def get_history(self, username, block_key, scope=Scope.user_state): def get_history(self, username, block_key, scope=Scope.user_state):
""" """
Retrieve history of state changes for a given block for a given Retrieve history of state changes for a given block for a given
student. We don't guarantee that history for many blocks will be fast. student. We don't guarantee that history for many blocks will be fast.
If the specified block doesn't exist, raise :class:`~DoesNotExist`.
Arguments: Arguments:
username: The name of the user whose history should be retrieved username: The name of the user whose history should be retrieved.
block_key (UsageKey): The UsageKey identifying which xblock state to update. block_key: The key identifying which xblock history to retrieve.
scope (Scope): The scope to load data from scope (Scope): The scope to load data from.
Yields:
XBlockUserState entries for each modification to the specified XBlock, from latest
to earliest.
""" """
if scope != Scope.user_state: if scope != Scope.user_state:
...@@ -243,19 +231,32 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient): ...@@ -243,19 +231,32 @@ class DjangoXBlockUserStateClient(XBlockUserStateClient):
if len(student_modules) == 0: if len(student_modules) == 0:
raise self.DoesNotExist() raise self.DoesNotExist()
history_entries = StudentModuleHistory.objects.filter( history_entries = StudentModuleHistory.objects.prefetch_related('student_module').filter(
student_module__in=student_modules student_module__in=student_modules
).order_by('-id') ).order_by('-id')
# If no history records exist, let's force a save to get history started. # If no history records exist, raise an error
if not history_entries: if not history_entries:
for student_module in student_modules: raise self.DoesNotExist()
student_module.save()
history_entries = StudentModuleHistory.objects.filter( for history_entry in history_entries:
student_module__in=student_modules state = history_entry.state
).order_by('-id')
# If the state is serialized json, then load it
if state is not None:
state = json.loads(state)
# If the state is empty, then for the purposes of `get_history`, it has been
# deleted, and so we list that entry as `None`.
if state == {}:
state = None
block_key = history_entry.student_module.module_state_key
block_key = block_key.map_into_course(
history_entry.student_module.course_id
)
return history_entries yield XBlockUserState(username, block_key, state, history_entry.created, scope)
def iter_all_for_block(self, block_key, scope=Scope.user_state, batch_size=None): def iter_all_for_block(self, block_key, scope=Scope.user_state, batch_size=None):
""" """
......
...@@ -44,6 +44,7 @@ from openedx.core.djangoapps.credit.api import ( ...@@ -44,6 +44,7 @@ from openedx.core.djangoapps.credit.api import (
is_user_eligible_for_credit, is_user_eligible_for_credit,
is_credit_course is_credit_course
) )
from courseware.models import StudentModuleHistory
from courseware.model_data import FieldDataCache, ScoresClient from courseware.model_data import FieldDataCache, ScoresClient
from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id from .module_render import toc_for_course, get_module_for_descriptor, get_module, get_module_by_usage_id
from .entrance_exams import ( from .entrance_exams import (
...@@ -1201,15 +1202,40 @@ def submission_history(request, course_id, student_username, location): ...@@ -1201,15 +1202,40 @@ def submission_history(request, course_id, student_username, location):
user_state_client = DjangoXBlockUserStateClient() user_state_client = DjangoXBlockUserStateClient()
try: try:
history_entries = user_state_client.get_history(student_username, usage_key) history_entries = list(user_state_client.get_history(student_username, usage_key))
except DjangoXBlockUserStateClient.DoesNotExist: except DjangoXBlockUserStateClient.DoesNotExist:
return HttpResponse(escape(_(u'User {username} has never accessed problem {location}').format( return HttpResponse(escape(_(u'User {username} has never accessed problem {location}').format(
username=student_username, username=student_username,
location=location location=location
))) )))
# This is ugly, but until we have a proper submissions API that we can use to provide
# the scores instead, it will have to do.
scores = list(StudentModuleHistory.objects.filter(
student_module__module_state_key=usage_key
).order_by('-id'))
if len(scores) != len(history_entries):
log.warning(
"Mismatch when fetching scores for student "
"history for course %s, user %s, xblock %s. "
"Matching scores by date for display.",
course_id,
student_username,
location
)
scores_by_date = {
score.modified: score
for score in scores
}
scores = [
scores_by_date[history.updated]
for history in history_entries
]
context = { context = {
'history_entries': history_entries, 'history_entries': history_entries,
'scores': scores,
'username': student_username, 'username': student_username,
'location': location, 'location': location,
'course_id': course_key.to_deprecated_string() 'course_id': course_key.to_deprecated_string()
......
<% import json %> <% import json %>
<h3>${username | h} > ${course_id | h} > ${location | h}</h3> <h3>${username | h} > ${course_id | h} > ${location | h}</h3>
% for i, entry in enumerate(history_entries): % for i, (entry, score) in enumerate(zip(history_entries, scores)):
<hr/> <hr/>
<div> <div>
<b>#${len(history_entries) - i}</b>: ${entry.created} (${TIME_ZONE} time)</br> <b>#${len(history_entries) - i}</b>: ${entry.updated} (${TIME_ZONE} time)</br>
Score: ${entry.grade} / ${entry.max_grade} Score: ${score.grade} / ${score.max_grade}
<pre> <pre>
${json.dumps(json.loads(entry.state), indent=2, sort_keys=True) | h} ${json.dumps(entry.state, indent=2, sort_keys=True) | h}
</pre> </pre>
</div> </div>
% endfor % endfor
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment