Commit 4dbbe513 by Nimisha Asthagiri

Reduce sql queries with persisted grades

TNL-5458
TNL-5493
parent bc966f0e
......@@ -719,7 +719,7 @@ def _progress(request, course_key, student_id):
# additional DB lookup (this kills the Progress page in particular).
student = User.objects.prefetch_related("groups").get(id=student.id)
course_grade = CourseGradeFactory(student).create(course)
course_grade = CourseGradeFactory(student).create(course, read_only=False)
if not course_grade.has_access_to_course:
# This means the student didn't have access to the course (which the instructor requested)
raise Http404
......
......@@ -9,11 +9,10 @@ from base64 import b64encode
from collections import namedtuple
from hashlib import sha1
import json
from lazy import lazy
import logging
from operator import attrgetter
from django.db import models, transaction
from django.db.utils import IntegrityError
from django.db import models
from model_utils.models import TimeStampedModel
from coursewarehistoryextended.fields import UnsignedBigIntAutoField
......@@ -34,60 +33,62 @@ class BlockRecordList(tuple):
An immutable ordered list of BlockRecord objects.
"""
def __new__(cls, blocks):
return super(BlockRecordList, cls).__new__(cls, tuple(blocks))
def __new__(cls, blocks, course_key): # pylint: disable=unused-argument
return super(BlockRecordList, cls).__new__(cls, blocks)
def __init__(self, blocks):
def __init__(self, blocks, course_key):
super(BlockRecordList, self).__init__(blocks)
self._json = None
self._hash = None
self.course_key = course_key
def _get_course_key_string(self):
def __eq__(self, other):
assert isinstance(other, BlockRecordList)
return hash(self) == hash(other)
def __hash__(self):
"""
Get the course key as a string. All blocks are from the same course,
so just grab one arbitrarily. If no blocks are present, return None.
Returns an integer Type value of the hash of this
list of block records, as required by python.
"""
if self:
a_block = next(iter(self))
return unicode(a_block.locator.course_key)
else:
return None
return hash(self.hash_value)
@lazy
def hash_value(self):
"""
Returns a hash value of the list of block records.
This currently hashes using sha1, and returns a base64 encoded version
of the binary digest. In the future, different algorithms could be
supported by adding a label indicated which algorithm was used, e.g.,
"sha256$j0NDRmSPa5bfid2pAcUXaxCm2Dlh3TwayItZstwyeqQ=".
"""
return b64encode(sha1(self.json_value).digest())
def to_json(self):
@lazy
def json_value(self):
"""
Return a JSON-serialized version of the list of block records, using a
stable ordering.
"""
if self._json is None:
list_of_block_dicts = [block._asdict() for block in self]
course_key_string = self._get_course_key_string() # all blocks are from the same course
for block_dict in list_of_block_dicts:
block_dict['locator'] = unicode(block_dict['locator']) # BlockUsageLocator is not json-serializable
data = {
'course_key': course_key_string,
'blocks': list_of_block_dicts,
}
self._json = json.dumps(
data,
separators=(',', ':'), # Remove spaces from separators for more compact representation
sort_keys=True,
)
return self._json
list_of_block_dicts = [block._asdict() for block in self]
for block_dict in list_of_block_dicts:
block_dict['locator'] = unicode(block_dict['locator']) # BlockUsageLocator is not json-serializable
data = {
'course_key': unicode(self.course_key),
'blocks': list_of_block_dicts,
}
return json.dumps(
data,
separators=(',', ':'), # Remove spaces from separators for more compact representation
sort_keys=True,
)
@classmethod
def from_json(cls, blockrecord_json):
"""
Return a BlockRecordList from a previously serialized json.
Return a BlockRecordList from previously serialized json.
"""
data = json.loads(blockrecord_json)
course_key = data['course_key']
if course_key is not None:
course_key = CourseKey.from_string(course_key)
else:
# If there was no course key, there are no blocks.
assert len(data['blocks']) == 0
course_key = CourseKey.from_string(data['course_key'])
block_dicts = data['blocks']
record_generator = (
BlockRecord(
......@@ -97,27 +98,14 @@ class BlockRecordList(tuple):
)
for block in block_dicts
)
return cls(record_generator)
return cls(record_generator, course_key)
@classmethod
def from_list(cls, blocks):
def from_list(cls, blocks, course_key):
"""
Return a BlockRecordList from a list.
Return a BlockRecordList from the given list and course_key.
"""
return cls(tuple(blocks))
def to_hash(self):
"""
Return a hashed version of the list of block records.
This currently hashes using sha1, and returns a base64 encoded version
of the binary digest. In the future, different algorithms could be
supported by adding a label indicated which algorithm was used, e.g.,
"sha256$j0NDRmSPa5bfid2pAcUXaxCm2Dlh3TwayItZstwyeqQ=".
"""
if self._hash is None:
self._hash = b64encode(sha1(self.to_json()).digest())
return self._hash
return cls(blocks, course_key)
class VisibleBlocksQuerySet(models.QuerySet):
......@@ -131,27 +119,12 @@ class VisibleBlocksQuerySet(models.QuerySet):
Argument 'blocks' should be a BlockRecordList.
"""
model, _ = self.get_or_create(hashed=blocks.to_hash(), defaults={'blocks_json': blocks.to_json()})
model, _ = self.get_or_create(
hashed=blocks.hash_value,
defaults={'blocks_json': blocks.json_value, 'course_id': blocks.course_key},
)
return model
def hash_from_blockrecords(self, blocks):
"""
Return the hash for a given list of blocks, saving the records if
possible, but returning the hash even if an IntegrityError occurs.
Argument 'blocks' should be a BlockRecordList.
"""
try:
with transaction.atomic():
model = self.create_from_blockrecords(blocks)
except IntegrityError:
# If an integrity error occurs, the VisibleBlocks model we want to
# create already exists. The hash is still the correct value.
return blocks.to_hash()
else:
# No error occurred
return model.hashed
class VisibleBlocks(models.Model):
"""
......@@ -164,6 +137,7 @@ class VisibleBlocks(models.Model):
"""
blocks_json = models.TextField()
hashed = models.CharField(max_length=100, unique=True)
course_id = CourseKeyField(blank=False, max_length=255, db_index=True)
objects = VisibleBlocksQuerySet.as_manager()
......@@ -181,37 +155,41 @@ class VisibleBlocks(models.Model):
"""
return BlockRecordList.from_json(self.blocks_json)
class PersistentSubsectionGradeQuerySet(models.QuerySet):
"""
A custom QuerySet, that handles creating a VisibleBlocks model on creation, and
extracts the course id from the provided usage_key.
"""
def create(self, **kwargs):
@classmethod
def bulk_read(cls, course_key):
"""
Instantiates a new model instance after creating a VisibleBlocks instance.
Reads all visible block records for the given course.
Arguments:
user_id (int)
usage_key (serialized UsageKey)
course_version (str)
subtree_edited_timestamp (datetime)
earned_all (float)
possible_all (float)
earned_graded (float)
possible_graded (float)
visible_blocks (iterable of BlockRecord)
"""
visible_blocks = kwargs.pop('visible_blocks')
kwargs['course_version'] = kwargs.get('course_version', None) or ""
if not kwargs.get('course_id', None):
kwargs['course_id'] = kwargs['usage_key'].course_key
visible_blocks_hash = VisibleBlocks.objects.hash_from_blockrecords(BlockRecordList.from_list(visible_blocks))
return super(PersistentSubsectionGradeQuerySet, self).create(
visible_blocks_id=visible_blocks_hash,
**kwargs
)
course_key: The course identifier for the desired records
"""
return cls.objects.filter(course_id=course_key)
@classmethod
def bulk_create(cls, block_record_lists):
"""
Bulk creates VisibleBlocks for the given iterator of
BlockRecordList objects.
"""
return cls.objects.bulk_create([
VisibleBlocks(
blocks_json=brl.json_value,
hashed=brl.hash_value,
course_id=brl.course_key,
)
for brl in block_record_lists
])
@classmethod
def bulk_get_or_create(cls, block_record_lists, course_key):
"""
Bulk creates VisibleBlocks for the given iterator of
BlockRecordList objects for the given course_key, but
only for those that aren't already created.
"""
existent_records = {record.hashed: record for record in cls.bulk_read(course_key)}
non_existent_brls = {brl for brl in block_record_lists if brl.hash_value not in existent_records}
cls.bulk_create(non_existent_brls)
class PersistentSubsectionGrade(TimeStampedModel):
......@@ -233,6 +211,10 @@ class PersistentSubsectionGrade(TimeStampedModel):
# uniquely identify this particular grade object
user_id = models.IntegerField(blank=False)
course_id = CourseKeyField(blank=False, max_length=255)
# note: the usage_key may not have the run filled in for
# old mongo courses. Use the full_usage_key property
# instead when you want to use/compare the usage_key.
usage_key = UsageKeyField(blank=False, max_length=255)
# Information relating to the state of content when grade was calculated
......@@ -249,8 +231,15 @@ class PersistentSubsectionGrade(TimeStampedModel):
# track which blocks were visible at the time of grade calculation
visible_blocks = models.ForeignKey(VisibleBlocks, db_column='visible_blocks_hash', to_field='hashed')
# use custom manager
objects = PersistentSubsectionGradeQuerySet.as_manager()
@property
def full_usage_key(self):
"""
Returns the "correct" usage key value with the run filled in.
"""
if self.usage_key.run is None: # pylint: disable=no-member
return self.usage_key.replace(course_key=self.course_id)
else:
return self.usage_key
def __unicode__(self):
"""
......@@ -269,38 +258,6 @@ class PersistentSubsectionGrade(TimeStampedModel):
)
@classmethod
def save_grade(cls, **kwargs):
"""
Wrapper for create_grade or update_grade, depending on which applies.
Takes the same arguments as both of those methods.
"""
user_id = kwargs.pop('user_id')
usage_key = kwargs.pop('usage_key')
try:
with transaction.atomic():
grade, is_created = cls.objects.get_or_create(
user_id=user_id,
course_id=usage_key.course_key,
usage_key=usage_key,
defaults=kwargs,
)
log.info(u"Persistent Grades: Grade model saved: {0}".format(grade))
except IntegrityError:
cls.update_grade(user_id=user_id, usage_key=usage_key, **kwargs)
log.warning(
u"Persistent Grades: Integrity error trying to save grade for user: {0}, usage key: {1}, defaults: {2}"
.format(
user_id,
usage_key,
**kwargs
)
)
else:
if not is_created:
grade.update(**kwargs)
@classmethod
def read_grade(cls, user_id, usage_key):
"""
Reads a grade from database
......@@ -311,74 +268,94 @@ class PersistentSubsectionGrade(TimeStampedModel):
Raises PersistentSubsectionGrade.DoesNotExist if applicable
"""
return cls.objects.get(
return cls.objects.select_related('visible_blocks').get(
user_id=user_id,
course_id=usage_key.course_key, # course_id is included to take advantage of db indexes
usage_key=usage_key,
)
@classmethod
def update_grade(
cls,
user_id,
usage_key,
course_version,
subtree_edited_timestamp,
earned_all,
possible_all,
earned_graded,
possible_graded,
visible_blocks,
):
"""
Updates a previously existing grade.
This is distinct from update() in that `grade.update()` operates on an
existing grade object, while this is a classmethod that pulls the grade
from the database, and then updates it. If you already have a grade
object, use the update() method on that object to avoid an extra
round-trip to the database. Use this classmethod if all you have are a
user and the usage key of an existing grade.
Requires all the arguments listed in docstring for create_grade
"""
grade = cls.read_grade(
def bulk_read_grades(cls, user_id, course_key):
"""
Reads all grades for the given user and course.
Arguments:
user_id: The user associated with the desired grades
course_key: The course identifier for the desired grades
"""
return cls.objects.select_related('visible_blocks').filter(
user_id=user_id,
usage_key=usage_key,
course_id=course_key,
)
grade.update(
course_version=course_version,
subtree_edited_timestamp=subtree_edited_timestamp,
earned_all=earned_all,
possible_all=possible_all,
earned_graded=earned_graded,
possible_graded=possible_graded,
visible_blocks=visible_blocks,
@classmethod
def update_or_create_grade(cls, **kwargs):
"""
Wrapper for objects.update_or_create.
"""
cls._prepare_params_and_visible_blocks(kwargs)
user_id = kwargs.pop('user_id')
usage_key = kwargs.pop('usage_key')
grade, _ = cls.objects.update_or_create(
user_id=user_id,
course_id=usage_key.course_key,
usage_key=usage_key,
defaults=kwargs,
)
return grade
@classmethod
def create_grade(cls, **kwargs):
"""
Wrapper for objects.create.
"""
cls._prepare_params_and_visible_blocks(kwargs)
return cls.objects.create(**kwargs)
@classmethod
def bulk_create_grades(cls, grade_params_iter, course_key):
"""
Bulk creation of grades.
"""
if not grade_params_iter:
return
map(cls._prepare_params, grade_params_iter)
VisibleBlocks.bulk_get_or_create([params['visible_blocks'] for params in grade_params_iter], course_key)
map(cls._prepare_params_visible_blocks_id, grade_params_iter)
return cls.objects.bulk_create([PersistentSubsectionGrade(**params) for params in grade_params_iter])
def update(
self,
course_version,
subtree_edited_timestamp,
earned_all,
possible_all,
earned_graded,
possible_graded,
visible_blocks,
):
"""
Modify an existing PersistentSubsectionGrade object, saving the new
version.
"""
visible_blocks_hash = VisibleBlocks.objects.hash_from_blockrecords(BlockRecordList.from_list(visible_blocks))
self.course_version = course_version or ""
self.subtree_edited_timestamp = subtree_edited_timestamp
self.earned_all = earned_all
self.possible_all = possible_all
self.earned_graded = earned_graded
self.possible_graded = possible_graded
self.visible_blocks_id = visible_blocks_hash # pylint: disable=attribute-defined-outside-init
self.save()
log.info(u"Persistent Grades: Grade model updated: {0}".format(self))
@classmethod
def _prepare_params_and_visible_blocks(cls, params):
"""
Prepares the fields for the grade record, while
creating the related VisibleBlocks, if needed.
"""
cls._prepare_params(params)
params['visible_blocks'] = VisibleBlocks.objects.create_from_blockrecords(params['visible_blocks'])
@classmethod
def _prepare_params(cls, params):
"""
Prepares the fields for the grade record.
"""
if not params.get('course_id', None):
params['course_id'] = params['usage_key'].course_key
params['course_version'] = params.get('course_version', None) or ""
params['visible_blocks'] = BlockRecordList.from_list(params['visible_blocks'], params['course_id'])
@classmethod
def _prepare_params_visible_blocks_id(cls, params):
"""
Prepares the visible_blocks_id field for the grade record,
using the hash of the visible_blocks field. Specifying
the hashed field eliminates extra queries to get the
VisibleBlocks record. Use this variation of preparing
the params when you are sure of the existence of the
VisibleBlock.
"""
params['visible_blocks_id'] = params['visible_blocks'].hash_value
del params['visible_blocks']
......@@ -40,10 +40,7 @@ class CourseGrade(object):
graded_total = subsection_grade.graded_total
if graded_total.possible > 0:
subsections_by_format[subsection_grade.format].append(graded_total)
log.info(u"Persistent Grades: Calculated subsections_by_format. course id: {0}, user: {1}".format(
self.course.location,
self.student.id
))
self._log_event(log.info, u"subsections_by_format")
return subsections_by_format
@lazy
......@@ -55,10 +52,7 @@ class CourseGrade(object):
for chapter in self.chapter_grades:
for subsection_grade in chapter['sections']:
locations_to_weighted_scores.update(subsection_grade.locations_to_weighted_scores)
log.info(u"Persistent Grades: Calculated locations_to_weighted_scores. course id: {0}, user: {1}".format(
self.course.id,
self.student.id
))
self._log_event(log.info, u"locations_to_weighted_scores")
return locations_to_weighted_scores
@lazy
......@@ -72,10 +66,7 @@ class CourseGrade(object):
self.subsection_grade_totals_by_format,
generate_random_scores=settings.GENERATE_PROFILE_SCORES
)
log.info(u"Persistent Grades: Calculated grade_value. course id: {0}, user: {1}".format(
self.course.location,
self.student.id
))
self._log_event(log.info, u"grade_value")
return grade_value
@property
......@@ -123,31 +114,34 @@ class CourseGrade(object):
grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format
grade_summary['raw_scores'] = list(self.locations_to_weighted_scores.itervalues())
self._log_event(log.warning, u"grade_summary, percent: {0}, grade: {1}".format(self.percent, self.letter_grade))
return grade_summary
def compute(self):
def compute_and_update(self, read_only=False):
"""
Computes the grade for the given student and course.
If read_only is True, doesn't save any updates to the grades.
"""
subsection_grade_factory = SubsectionGradeFactory(self.student)
self._log_event(log.warning, u"compute_and_update, read_only: {}".format(read_only))
subsection_grade_factory = SubsectionGradeFactory(self.student, self.course, self.course_structure)
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
subsection_grades = []
chapter_subsection_grades = []
for subsection_key in self.course_structure.get_children(chapter_key):
subsection_grades.append(
subsection_grade_factory.create(
self.course_structure[subsection_key],
self.course_structure,
self.course,
)
chapter_subsection_grades.append(
subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
self.chapter_grades.append({
'display_name': block_metadata_utils.display_name_with_default_escaped(chapter),
'url_name': block_metadata_utils.url_name_for_block(chapter),
'sections': subsection_grades
'sections': chapter_subsection_grades
})
if not read_only:
subsection_grade_factory.bulk_create_unsaved()
self._signal_listeners_when_grade_computed()
def score_for_module(self, location):
......@@ -212,6 +206,16 @@ class CourseGrade(object):
receiver, response
)
def _log_event(self, log_func, log_statement):
"""
Logs the given statement, for this instance.
"""
log_func(u"Persistent Grades: CourseGrade.{0}, course: {1}, user: {2}".format(
log_statement,
self.course.id,
self.student.id
))
class CourseGradeFactory(object):
"""
......@@ -220,22 +224,26 @@ class CourseGradeFactory(object):
def __init__(self, student):
self.student = student
def create(self, course):
def create(self, course, read_only=False):
"""
Returns the CourseGrade object for the given student and course.
If read_only is True, doesn't save any updates to the grades.
"""
course_structure = get_course_blocks(self.student, course.location)
return (
self._get_saved_grade(course, course_structure) or
self._compute_and_update_grade(course, course_structure)
self._compute_and_update_grade(course, course_structure, read_only)
)
def _compute_and_update_grade(self, course, course_structure):
def _compute_and_update_grade(self, course, course_structure, read_only):
"""
Freshly computes and updates the grade for the student and course.
If read_only is True, doesn't save any updates to the grades.
"""
course_grade = CourseGrade(self.student, course, course_structure)
course_grade.compute()
course_grade.compute_and_update(read_only)
return course_grade
def _get_saved_grade(self, course, course_structure): # pylint: disable=unused-argument
......
......@@ -21,7 +21,7 @@ class SubsectionGrade(object):
"""
Class for Subsection Grades.
"""
def __init__(self, subsection):
def __init__(self, subsection, course):
self.location = subsection.location
self.display_name = block_metadata_utils.display_name_with_default_escaped(subsection)
self.url_name = block_metadata_utils.url_name_for_block(subsection)
......@@ -30,59 +30,48 @@ class SubsectionGrade(object):
self.due = getattr(subsection, 'due', None)
self.graded = getattr(subsection, 'graded', False)
self.course_version = getattr(course, 'course_version', None)
self.subtree_edited_timestamp = subsection.subtree_edited_on
self.graded_total = None # aggregated grade for all graded problems
self.all_total = None # aggregated grade for all problems, regardless of whether they are graded
self.locations_to_weighted_scores = OrderedDict() # dict of problem locations to (Score, weight) tuples
@lazy
self._scores = None
@property
def scores(self):
"""
List of all problem scores in the subsection.
"""
log.info(u"Persistent Grades: calculated scores property for subsection {0}".format(self.location))
return [score for score, _ in self.locations_to_weighted_scores.itervalues()]
if self._scores is None:
self._scores = [score for score, _ in self.locations_to_weighted_scores.itervalues()]
return self._scores
def compute(self, student, course_structure, scores_client, submissions_scores):
def init_from_structure(self, student, course_structure, scores_client, submissions_scores):
"""
Compute the grade of this subsection for the given student and course.
"""
lazy.invalidate(self, 'scores')
assert self._scores is None
for descendant_key in course_structure.post_order_traversal(
filter_func=possibly_scored,
start_node=self.location,
):
self._compute_block_score(student, descendant_key, course_structure, scores_client, submissions_scores)
self._compute_block_score(
student, descendant_key, course_structure, scores_client, submissions_scores, persisted_values={},
)
self.all_total, self.graded_total = graders.aggregate_scores(self.scores, self.display_name, self.location)
self._log_event(log.warning, u"init_from_structure", student)
def save(self, student, subsection, course):
"""
Persist the SubsectionGrade.
"""
visible_blocks = [
BlockRecord(location, weight, score.possible)
for location, (score, weight) in self.locations_to_weighted_scores.iteritems()
]
PersistentSubsectionGrade.save_grade(
user_id=student.id,
usage_key=self.location,
course_version=getattr(course, 'course_version', None),
subtree_edited_timestamp=subsection.subtree_edited_on,
earned_all=self.all_total.earned,
possible_all=self.all_total.possible,
earned_graded=self.graded_total.earned,
possible_graded=self.graded_total.possible,
visible_blocks=visible_blocks,
)
def load_from_data(self, model, course_structure, scores_client, submissions_scores):
def init_from_model(self, student, model, course_structure, scores_client, submissions_scores):
"""
Load the subsection grade from the persisted model.
"""
assert self._scores is None
for block in model.visible_blocks.blocks:
persisted_values = {'weight': block.weight, 'possible': block.max_score}
self._compute_block_score(
User.objects.get(id=model.user_id),
student,
block.locator,
course_structure,
scores_client,
......@@ -104,21 +93,32 @@ class SubsectionGrade(object):
section=self.display_name,
module_id=self.location,
)
self._log_event(log.warning, u"init_from_model", student)
def __unicode__(self):
@classmethod
def bulk_create_models(cls, student, subsection_grades, course_key):
"""
Provides a unicode representation of the scoring
data for this subsection. Used for logging.
Saves the subsection grade in a persisted model.
"""
return u"SubsectionGrade|total: {0}/{1}|graded: {2}/{3}|location: {4}|display name: {5}".format(
self.all_total.earned,
self.all_total.possible,
self.graded_total.earned,
self.graded_total.possible,
self.location,
self.display_name
return PersistentSubsectionGrade.bulk_create_grades(
[subsection_grade._persisted_model_params(student) for subsection_grade in subsection_grades], # pylint: disable=protected-access
course_key,
)
def create_model(self, student):
"""
Saves the subsection grade in a persisted model.
"""
self._log_event(log.info, u"create_model", student)
return PersistentSubsectionGrade.create_grade(**self._persisted_model_params(student))
def update_or_create_model(self, student):
"""
Saves or updates the subsection grade in a persisted model.
"""
self._log_event(log.info, u"update_or_create_model", student)
return PersistentSubsectionGrade.update_or_create_grade(**self._persisted_model_params(student))
def _compute_block_score(
self,
student,
......@@ -126,30 +126,35 @@ class SubsectionGrade(object):
course_structure,
scores_client,
submissions_scores,
persisted_values=None,
persisted_values,
):
"""
Compute score for the given block. If persisted_values is provided, it will be used for possible and weight.
Compute score for the given block. If persisted_values
is provided, it is used for possible and weight.
"""
block = course_structure[block_key]
if getattr(block, 'has_score', False):
possible = persisted_values.get('possible', None)
weight = persisted_values.get('weight', getattr(block, 'weight', None))
(earned, possible) = get_score(
student,
block,
scores_client,
submissions_scores,
weight,
possible,
)
# There's a chance that the value of weight is not the same value used when the problem was scored,
# since we can get the value from either block_structure or CSM/submissions.
weight = getattr(block, 'weight', None)
if persisted_values:
possible = persisted_values.get('possible', possible)
weight = persisted_values.get('weight', weight)
if earned is not None or possible is not None:
# cannot grade a problem with a denominator of 0
# There's a chance that the value of graded is not the same
# value when the problem was scored. Since we get the value
# from the block_structure.
#
# Cannot grade a problem with a denominator of 0.
# TODO: None > 0 is not python 3 compatible.
block_graded = block.graded if possible > 0 else False
self.locations_to_weighted_scores[block.location] = (
......@@ -163,110 +168,202 @@ class SubsectionGrade(object):
weight,
)
def _persisted_model_params(self, student):
"""
Returns the parameters for creating/updating the
persisted model for this subsection grade.
"""
return dict(
user_id=student.id,
usage_key=self.location,
course_version=self.course_version,
subtree_edited_timestamp=self.subtree_edited_timestamp,
earned_all=self.all_total.earned,
possible_all=self.all_total.possible,
earned_graded=self.graded_total.earned,
possible_graded=self.graded_total.possible,
visible_blocks=self._get_visible_blocks,
)
@property
def _get_visible_blocks(self):
"""
Returns the list of visible blocks.
"""
return [
BlockRecord(location, weight, score.possible)
for location, (score, weight) in
self.locations_to_weighted_scores.iteritems()
]
def _log_event(self, log_func, log_statement, student):
"""
Logs the given statement, for this instance.
"""
log_func(
u"Persistent Grades: SG.{}, subsection: {}, course: {}, "
u"version: {}, edit: {}, user: {},"
u"total: {}/{}, graded: {}/{}".format(
log_statement,
self.location,
self.location.course_key,
self.course_version,
self.subtree_edited_timestamp,
student.id,
self.all_total.earned,
self.all_total.possible,
self.graded_total.earned,
self.graded_total.possible,
)
)
class SubsectionGradeFactory(object):
"""
Factory for Subsection Grades.
"""
def __init__(self, student):
def __init__(self, student, course, course_structure):
self.student = student
self.course = course
self.course_structure = course_structure
self._scores_client = None
self._submissions_scores = None
self._cached_subsection_grades = None
self._unsaved_subsection_grades = []
def create(self, subsection, course_structure, course):
def create(self, subsection, block_structure=None, read_only=False):
"""
Returns the SubsectionGrade object for the student and subsection.
If block_structure is provided, uses it for finding and computing
the grade instead of the course_structure passed in earlier.
If read_only is True, doesn't save any updates to the grades.
"""
self._prefetch_scores(course_structure, course)
return (
self._get_saved_grade(subsection, course_structure, course) or
self._compute_and_save_grade(subsection, course_structure, course)
self._log_event(
log.warning, u"create, read_only: {0}, subsection: {1}".format(read_only, subsection.location)
)
def update(self, usage_key, course_structure, course):
block_structure = self._get_block_structure(block_structure)
subsection_grade = self._get_saved_grade(subsection, block_structure)
if not subsection_grade:
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_structure(
self.student, block_structure, self._scores_client, self._submissions_scores
)
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
if read_only:
self._unsaved_subsection_grades.append(subsection_grade)
else:
grade_model = subsection_grade.create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return subsection_grade
def bulk_create_unsaved(self):
"""
Updates the SubsectionGrade object for the student and subsection
identified by the given usage key.
Bulk creates all the unsaved subsection_grades to this point.
"""
# save ourselves the extra queries if the course does not use subsection grades
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return
self._log_event(log.warning, u"bulk_create_unsaved")
self._prefetch_scores(course_structure, course)
subsection = course_structure[usage_key]
return self._compute_and_save_grade(subsection, course_structure, course)
SubsectionGrade.bulk_create_models(self.student, self._unsaved_subsection_grades, self.course.id)
self._unsaved_subsection_grades = []
def _compute_and_save_grade(self, subsection, course_structure, course):
def update(self, subsection, block_structure=None):
"""
Freshly computes and updates the grade for the student and subsection.
Updates the SubsectionGrade object for the student and subsection.
"""
subsection_grade = SubsectionGrade(subsection)
subsection_grade.compute(self.student, course_structure, self._scores_client, self._submissions_scores)
self._save_grade(subsection_grade, subsection, course)
# Save ourselves the extra queries if the course does not persist
# subsection grades.
if not PersistentGradesEnabledFlag.feature_enabled(self.course.id):
return
self._log_event(log.warning, u"update, subsection: {}".format(subsection.location))
block_structure = self._get_block_structure(block_structure)
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_structure(
self.student, block_structure, self._scores_client, self._submissions_scores
)
grade_model = subsection_grade.update_or_create_model(self.student)
self._update_saved_subsection_grade(subsection.location, grade_model)
return subsection_grade
def _get_saved_grade(self, subsection, course_structure, course): # pylint: disable=unused-argument
@lazy
def _scores_client(self):
"""
Returns the saved grade for the student and subsection.
Lazily queries and returns all the scores stored in the user
state (in CSM) for the course, while caching the result.
"""
if PersistentGradesEnabledFlag.feature_enabled(course.id):
try:
model = PersistentSubsectionGrade.read_grade(
user_id=self.student.id,
usage_key=subsection.location,
)
subsection_grade = SubsectionGrade(subsection)
subsection_grade.load_from_data(model, course_structure, self._scores_client, self._submissions_scores)
log.warning(
u"Persistent Grades: Loaded grade for course id: {0}, version: {1}, subtree edited on: {2},"
u" grade: {3}, user: {4}".format(
course.id,
getattr(course, 'course_version', None),
course.subtree_edited_on,
subsection_grade,
self.student.id
)
)
return subsection_grade
except PersistentSubsectionGrade.DoesNotExist:
log.warning(
u"Persistent Grades: Could not find grade for course id: {0}, version: {1}, subtree edited"
u" on: {2}, subsection: {3}, user: {4}".format(
course.id,
getattr(course, 'course_version', None),
course.subtree_edited_on,
subsection.location,
self.student.id
)
)
return None
scorable_locations = [block_key for block_key in self.course_structure if possibly_scored(block_key)]
return ScoresClient.create_for_locations(self.course.id, self.student.id, scorable_locations)
def _save_grade(self, subsection_grade, subsection, course): # pylint: disable=unused-argument
@lazy
def _submissions_scores(self):
"""
Updates the saved grade for the student and subsection.
Lazily queries and returns the scores stored by the
Submissions API for the course, while caching the result.
"""
if PersistentGradesEnabledFlag.feature_enabled(course.id):
subsection_grade.save(self.student, subsection, course)
log.warning(u"Persistent Grades: Saved grade for course id: {0}, version: {1}, subtree_edited_on: {2}, grade: "
u"{3}, user: {4}"
.format(
course.id,
getattr(course, 'course_version', None),
course.subtree_edited_on,
subsection_grade,
self.student.id
))
anonymous_user_id = anonymous_id_for_user(self.student, self.course.id)
return submissions_api.get_scores(unicode(self.course.id), anonymous_user_id)
def _prefetch_scores(self, course_structure, course):
def _get_saved_grade(self, subsection, block_structure): # pylint: disable=unused-argument
"""
Returns the prefetched scores for the given student and course.
Returns the saved grade for the student and subsection.
"""
if not self._scores_client:
scorable_locations = [block_key for block_key in course_structure if possibly_scored(block_key)]
self._scores_client = ScoresClient.create_for_locations(
course.id, self.student.id, scorable_locations
)
self._submissions_scores = submissions_api.get_scores(
unicode(course.id), anonymous_id_for_user(self.student, course.id)
if not PersistentGradesEnabledFlag.feature_enabled(self.course.id):
return
saved_subsection_grade = self._get_saved_subsection_grade(subsection.location)
if saved_subsection_grade:
subsection_grade = SubsectionGrade(subsection, self.course)
subsection_grade.init_from_model(
self.student, saved_subsection_grade, block_structure, self._scores_client, self._submissions_scores
)
return subsection_grade
def _get_saved_subsection_grade(self, subsection_usage_key):
"""
Returns the saved value of the subsection grade for
the given subsection usage key, caching the value.
Returns None if not found.
"""
if self._cached_subsection_grades is None:
self._cached_subsection_grades = {
record.full_usage_key: record
for record in PersistentSubsectionGrade.bulk_read_grades(self.student.id, self.course.id)
}
return self._cached_subsection_grades.get(subsection_usage_key)
def _update_saved_subsection_grade(self, subsection_usage_key, subsection_model):
"""
Updates (or adds) the subsection grade for the given
subsection usage key in the local cache, iff the cache
is populated.
"""
if self._cached_subsection_grades is not None:
self._cached_subsection_grades[subsection_usage_key] = subsection_model
def _get_block_structure(self, block_structure):
"""
If block_structure is None, returns self.course_structure.
Otherwise, returns block_structure after verifying that the
given block_structure is a sub-structure of self.course_structure.
"""
if block_structure:
if block_structure.root_block_usage_key not in self.course_structure:
raise ValueError
return block_structure
else:
return self.course_structure
def _log_event(self, log_func, log_statement):
"""
Logs the given statement, for this instance.
"""
log_func(u"Persistent Grades: SGF.{}, course: {}, version: {}, edit: {}, user: {}".format(
log_statement,
self.course.id,
getattr(self.course, 'course_version', None),
self.course.subtree_edited_on,
self.student.id,
))
"""
Functionality for problem scores.
"""
from logging import getLogger
from openedx.core.lib.cache_utils import memoized
from xblock.core import XBlock
from .transformer import GradesTransformer
log = getLogger(__name__)
@memoized
def block_types_possibly_scored():
"""
......@@ -30,15 +35,17 @@ def possibly_scored(usage_key):
return usage_key.block_type in block_types_possibly_scored()
def weighted_score(raw_earned, raw_possible, weight):
"""Return a tuple that represents the weighted (correct, total) score."""
# If there is no weighting, or weighting can't be applied, return input.
def weighted_score(raw_earned, raw_possible, weight=None):
"""
Return a tuple that represents the weighted (earned, possible) score.
If weight is None or raw_possible is 0, returns the original values.
"""
if weight is None or raw_possible == 0:
return (raw_earned, raw_possible)
return float(raw_earned) * weight / raw_possible, float(weight)
def get_score(user, block, scores_client, submissions_scores_cache):
def get_score(user, block, scores_client, submissions_scores_cache, weight, possible=None):
"""
Return the score for a user on a problem, as a tuple (earned, possible).
e.g. (5,7) if you got 5 out of 7 points.
......@@ -49,8 +56,13 @@ def get_score(user, block, scores_client, submissions_scores_cache):
user: a Student object
block: a BlockStructure's BlockData object
scores_client: an initialized ScoresClient
submissions_scores_cache: A dict of location names to (earned, possible) point tuples.
If an entry is found in this cache, it takes precedence.
submissions_scores_cache: A dict of location names to (earned, possible)
point tuples. If an entry is found in this cache, it takes precedence.
weight: The weight of the problem to use in the calculation. A value of
None signifies that the weight should not be applied.
possible (optional): The possible maximum score of the problem to use in the
calculation. If None, uses the value found either in scores_client or
from the block.
"""
submissions_scores_cache = submissions_scores_cache or {}
......@@ -74,16 +86,20 @@ def get_score(user, block, scores_client, submissions_scores_cache):
if score and score.total is not None:
# We have a valid score, just use it.
earned = score.correct if score.correct is not None else 0.0
possible = score.total
if possible is None:
possible = score.total
elif possible != score.total:
log.error(u"Persisted Grades: possible value {} != score.total value {}".format(possible, score.total))
else:
# This means we don't have a valid score entry and we don't have a
# cached_max_score on hand. We know they've earned 0.0 points on this.
earned = 0.0
possible = block.transformer_data[GradesTransformer].max_score
if possible is None:
possible = block.transformer_data[GradesTransformer].max_score
# Problem may be an error module (if something in the problem builder failed)
# In which case possible might be None
if possible is None:
return (None, None)
return weighted_score(earned, possible, block.weight)
return weighted_score(earned, possible, weight)
......@@ -108,10 +108,13 @@ def recalculate_subsection_grade_handler(sender, **kwargs): # pylint: disable=u
'subsections',
set()
)
subsection_grade_factory = SubsectionGradeFactory(student, course, collected_block_structure)
for subsection_usage_key in subsections_to_update:
transformed_subsection_structure = get_course_blocks(
student,
subsection_usage_key,
collected_block_structure=collected_block_structure,
)
SubsectionGradeFactory(student).update(subsection_usage_key, transformed_subsection_structure, course)
subsection_grade_factory.update(
transformed_subsection_structure[subsection_usage_key], transformed_subsection_structure
)
......@@ -395,7 +395,7 @@ class TestGetModuleScore(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
# then stored in the request).
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user).create(self.seq1, self.course_structure, self.course)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, 0)
self.assertEqual(new_score.all_total.earned, 0)
......@@ -404,7 +404,7 @@ class TestGetModuleScore(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user).create(self.seq1, self.course_structure, self.course)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, 1.0)
self.assertEqual(new_score.all_total.earned, 2.0)
# These differ because get_module_score normalizes the subsection score
......@@ -416,7 +416,7 @@ class TestGetModuleScore(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
with self.assertNumQueries(1):
score = get_module_score(self.request.user, self.course, self.seq1)
new_score = SubsectionGradeFactory(self.request.user).create(self.seq1, self.course_structure, self.course)
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
self.assertEqual(score, .5)
self.assertEqual(new_score.all_total.earned, 1.0)
......
......@@ -6,7 +6,6 @@ from collections import OrderedDict
import ddt
from hashlib import sha1
import json
from mock import patch
from django.db.utils import IntegrityError
from django.test import TestCase
......@@ -24,17 +23,25 @@ class BlockRecordListTestCase(TestCase):
"""
Verify the behavior of BlockRecordList, particularly around edge cases
"""
empty_json = '{"blocks":[],"course_key":null}'
def setUp(self):
super(BlockRecordListTestCase, self).setUp()
self.course_key = CourseLocator(
org='some_org',
course='some_course',
run='some_run'
)
def test_empty_block_record_set(self):
brs = BlockRecordList(())
empty_json = '{0}"blocks":[],"course_key":"{1}"{2}'.format('{', unicode(self.course_key), '}')
brs = BlockRecordList((), self.course_key)
self.assertFalse(brs)
self.assertEqual(
brs.to_json(),
self.empty_json
brs.json_value,
empty_json
)
self.assertEqual(
BlockRecordList.from_json(self.empty_json),
BlockRecordList.from_json(empty_json),
brs
)
......@@ -112,7 +119,7 @@ class VisibleBlocksTest(GradesModelTestCase):
"""
Creates and returns a BlockRecordList for the given blocks.
"""
return VisibleBlocks.objects.create_from_blockrecords(BlockRecordList.from_list(blocks))
return VisibleBlocks.objects.create_from_blockrecords(BlockRecordList.from_list(blocks, self.course_key))
def test_creation(self):
"""
......@@ -159,7 +166,7 @@ class VisibleBlocksTest(GradesModelTestCase):
and accessing visible_blocks.blocks yields a copy of the initial array.
Also, trying to set the blocks property should raise an exception.
"""
expected_blocks = (self.record_a, self.record_b)
expected_blocks = BlockRecordList.from_list([self.record_a, self.record_b], self.course_key)
visible_blocks = self._create_block_record_list(expected_blocks)
self.assertEqual(expected_blocks, visible_blocks.blocks)
with self.assertRaises(AttributeError):
......@@ -178,6 +185,7 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
block_type='subsection',
block_id='subsection_12345',
)
self.block_records = BlockRecordList([self.record_a, self.record_b], self.course_key)
self.params = {
"user_id": 12345,
"usage_key": self.usage_key,
......@@ -187,21 +195,23 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
"possible_all": 12.0,
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": [self.record_a, self.record_b],
"visible_blocks": self.block_records,
}
def test_create(self):
"""
Tests model creation, and confirms error when trying to recreate model.
"""
created_grade = PersistentSubsectionGrade.objects.create(**self.params)
read_grade = PersistentSubsectionGrade.read_grade(
user_id=self.params["user_id"],
usage_key=self.params["usage_key"],
)
self.assertEqual(created_grade, read_grade)
created_grade = PersistentSubsectionGrade.create_grade(**self.params)
with self.assertNumQueries(1):
read_grade = PersistentSubsectionGrade.read_grade(
user_id=self.params["user_id"],
usage_key=self.params["usage_key"],
)
self.assertEqual(created_grade, read_grade)
self.assertEquals(read_grade.visible_blocks.blocks, self.block_records)
with self.assertRaises(IntegrityError):
created_grade = PersistentSubsectionGrade.objects.create(**self.params)
PersistentSubsectionGrade.create_grade(**self.params)
def test_create_bad_params(self):
"""
......@@ -209,56 +219,19 @@ class PersistentSubsectionGradeTest(GradesModelTestCase):
"""
del self.params["earned_graded"]
with self.assertRaises(IntegrityError):
PersistentSubsectionGrade.objects.create(**self.params)
PersistentSubsectionGrade.create_grade(**self.params)
def test_course_version_is_optional(self):
del self.params["course_version"]
PersistentSubsectionGrade.objects.create(**self.params)
def test_update_grade(self):
"""
Tests model update, and confirms error when updating a nonexistent model.
"""
with self.assertRaises(PersistentSubsectionGrade.DoesNotExist):
PersistentSubsectionGrade.update_grade(**self.params)
PersistentSubsectionGrade.objects.create(**self.params)
self.params['earned_all'] = 12.0
self.params['earned_graded'] = 8.0
with patch('lms.djangoapps.grades.models.log') as log_mock:
PersistentSubsectionGrade.update_grade(**self.params)
read_grade = PersistentSubsectionGrade.read_grade(
user_id=self.params["user_id"],
usage_key=self.params["usage_key"],
)
log_mock.info.assert_called_with(
u"Persistent Grades: Grade model updated: {0}".format(read_grade)
)
self.assertEqual(read_grade.earned_all, 12.0)
self.assertEqual(read_grade.earned_graded, 8.0)
PersistentSubsectionGrade.create_grade(**self.params)
@ddt.data(True, False)
def test_save(self, already_created):
if already_created:
PersistentSubsectionGrade.objects.create(**self.params)
module_prefix = "lms.djangoapps.grades.models.PersistentSubsectionGrade."
with patch(
module_prefix + "objects.get_or_create",
wraps=PersistentSubsectionGrade.objects.get_or_create
) as mock_get_or_create:
with patch(module_prefix + "update") as mock_update:
PersistentSubsectionGrade.save_grade(**self.params)
self.assertTrue(mock_get_or_create.called)
self.assertEqual(mock_update.called, already_created)
def test_update_or_create_grade(self, already_created):
created_grade = PersistentSubsectionGrade.create_grade(**self.params) if already_created else None
def test_logging_for_save(self):
with patch('lms.djangoapps.grades.models.log') as log_mock:
PersistentSubsectionGrade.save_grade(**self.params)
read_grade = PersistentSubsectionGrade.read_grade(
user_id=self.params["user_id"],
usage_key=self.params["usage_key"],
)
log_mock.info.assert_called_with(
u"Persistent Grades: Grade model saved: {0}".format(read_grade)
)
self.params["earned_all"] = 7
updated_grade = PersistentSubsectionGrade.update_or_create_grade(**self.params)
self.assertEquals(updated_grade.earned_all, 7)
if already_created:
self.assertEquals(created_grade.id, updated_grade.id)
self.assertEquals(created_grade.earned_all, 6)
......@@ -61,8 +61,8 @@ class GradeTestBase(SharedModuleStoreTestCase):
super(GradeTestBase, self).setUp()
self.request = get_request_for_user(UserFactory())
self.client.login(username=self.request.user.username, password="test")
self.subsection_grade_factory = SubsectionGradeFactory(self.request.user)
self.course_structure = get_course_blocks(self.request.user, self.course.location)
self.subsection_grade_factory = SubsectionGradeFactory(self.request.user, self.course, self.course_structure)
CourseEnrollment.enroll(self.request.user, self.course.id)
......@@ -110,33 +110,25 @@ class SubsectionGradeFactoryTest(GradeTestBase):
Tests to ensure that a persistent subsection grade is created, saved, then fetched on re-request.
"""
with patch(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._save_grade',
wraps=self.subsection_grade_factory._save_grade # pylint: disable=protected-access
) as mock_save_grades:
'lms.djangoapps.grades.new.subsection_grade.PersistentSubsectionGrade.create_grade',
wraps=PersistentSubsectionGrade.create_grade
) as mock_create_grade:
with patch(
'lms.djangoapps.grades.new.subsection_grade.SubsectionGradeFactory._get_saved_grade',
wraps=self.subsection_grade_factory._get_saved_grade # pylint: disable=protected-access
) as mock_get_saved_grade:
with self.assertNumQueries(19):
grade_a = self.subsection_grade_factory.create(
self.sequence,
self.course_structure,
self.course
)
with self.assertNumQueries(12):
grade_a = self.subsection_grade_factory.create(self.sequence)
self.assertTrue(mock_get_saved_grade.called)
self.assertTrue(mock_save_grades.called)
self.assertTrue(mock_create_grade.called)
mock_get_saved_grade.reset_mock()
mock_save_grades.reset_mock()
with self.assertNumQueries(3):
grade_b = self.subsection_grade_factory.create(
self.sequence,
self.course_structure,
self.course
)
mock_create_grade.reset_mock()
with self.assertNumQueries(0):
grade_b = self.subsection_grade_factory.create(self.sequence)
self.assertTrue(mock_get_saved_grade.called)
self.assertFalse(mock_save_grades.called)
self.assertFalse(mock_create_grade.called)
self.assertEqual(grade_a.url_name, grade_b.url_name)
self.assertEqual(grade_a.all_total, grade_b.all_total)
......@@ -153,7 +145,7 @@ class SubsectionGradeFactoryTest(GradeTestBase):
# Grades are only saved if the feature flag and the advanced setting are
# both set to True.
with patch(
'lms.djangoapps.grades.models.PersistentSubsectionGrade.read_grade'
'lms.djangoapps.grades.models.PersistentSubsectionGrade.bulk_read_grades'
) as mock_read_saved_grade:
with persistent_grades_feature_flags(
global_flag=feature_flag,
......@@ -161,7 +153,7 @@ class SubsectionGradeFactoryTest(GradeTestBase):
course_id=self.course.id,
enabled_for_course=course_setting
):
self.subsection_grade_factory.create(self.sequence, self.course_structure, self.course)
self.subsection_grade_factory.create(self.sequence)
self.assertEqual(mock_read_saved_grade.called, feature_flag and course_setting)
......@@ -174,10 +166,8 @@ class SubsectionGradeTest(GradeTestBase):
"""
Assuming the underlying score reporting methods work, test that the score is calculated properly.
"""
grade = self.subsection_grade_factory.create(self.sequence, self.course_structure, self.course)
with mock_get_score(1, 2):
# The final 2 parameters are only passed through to our mocked-out get_score method
grade.compute(self.request.user, self.course_structure, None, None)
grade = self.subsection_grade_factory.create(self.sequence)
self.assertEqual(grade.all_total.earned, 1)
self.assertEqual(grade.all_total.possible, 2)
......@@ -186,9 +176,8 @@ class SubsectionGradeTest(GradeTestBase):
Test that grades are persisted to the database properly, and that loading saved grades returns the same data.
"""
# Create a grade that *isn't* saved to the database
self.subsection_grade_factory._prefetch_scores(self.course_structure, self.course) # pylint: disable=protected-access
input_grade = SubsectionGrade(self.sequence)
input_grade.compute(
input_grade = SubsectionGrade(self.sequence, self.course)
input_grade.init_from_structure(
self.request.user,
self.course_structure,
self.subsection_grade_factory._scores_client, # pylint: disable=protected-access
......@@ -197,16 +186,17 @@ class SubsectionGradeTest(GradeTestBase):
self.assertEqual(PersistentSubsectionGrade.objects.count(), 0)
# save to db, and verify object is in database
input_grade.save(self.request.user, self.sequence, self.course)
input_grade.create_model(self.request.user)
self.assertEqual(PersistentSubsectionGrade.objects.count(), 1)
# load from db, and ensure output matches input
loaded_grade = SubsectionGrade(self.sequence)
loaded_grade = SubsectionGrade(self.sequence, self.course)
saved_model = PersistentSubsectionGrade.read_grade(
user_id=self.request.user.id,
usage_key=self.sequence.location,
)
loaded_grade.load_from_data(
loaded_grade.init_from_model(
self.request.user,
saved_model,
self.course_structure,
self.subsection_grade_factory._scores_client, # pylint: disable=protected-access
......
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