Files
edx-platform/lms/djangoapps/grades/course_grade_factory.py
2017-09-25 16:13:07 -04:00

230 lines
9.2 KiB
Python

from collections import namedtuple
from contextlib import contextmanager
from logging import getLogger
import dogstats_wrapper as dog_stats_api
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED, COURSE_GRADE_NOW_PASSED
from .config import assume_zero_if_absent, should_persist_grades
from .course_data import CourseData
from .course_grade import CourseGrade, ZeroCourseGrade
from .models import PersistentCourseGrade, VisibleBlocks
log = getLogger(__name__)
class CourseGradeFactory(object):
"""
Factory class to create Course Grade objects.
"""
GradeResult = namedtuple('GradeResult', ['student', 'course_grade', 'error'])
def create(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
"""
Returns the CourseGrade for the given user in the course.
Reads the value from storage and validates that the grading
policy hasn't changed since the grade was last computed.
If not in storage, returns a ZeroGrade if ASSUME_ZERO_GRADE_IF_ABSENT.
Else, if changed or not in storage, computes and returns a new value.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
try:
course_grade, read_policy_hash = self._read(user, course_data)
if read_policy_hash == course_data.grading_policy_hash:
return course_grade
read_only = False # update the persisted grade since the policy changed; TODO(TNL-6786) remove soon
except PersistentCourseGrade.DoesNotExist:
if assume_zero_if_absent(course_data.course_key):
return self._create_zero(user, course_data)
read_only = True # keep the grade un-persisted; TODO(TNL-6786) remove once all grades are backfilled
return self._update(user, course_data, read_only)
def read(self, user, course=None, collected_block_structure=None, course_structure=None, course_key=None):
"""
Returns the CourseGrade for the given user in the course as
persisted in storage. Does NOT verify whether the grading
policy is still valid since the grade was last computed.
If not in storage, returns a ZeroGrade if ASSUME_ZERO_GRADE_IF_ABSENT
else returns None.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
try:
course_grade, _ = self._read(user, course_data)
return course_grade
except PersistentCourseGrade.DoesNotExist:
if assume_zero_if_absent(course_data.course_key):
return self._create_zero(user, course_data)
else:
return None
def update(
self,
user,
course=None,
collected_block_structure=None,
course_structure=None,
course_key=None,
force_update_subsections=False,
):
"""
Computes, updates, and returns the CourseGrade for the given
user in the course.
At least one of course, collected_block_structure, course_structure,
or course_key should be provided.
"""
course_data = CourseData(user, course, collected_block_structure, course_structure, course_key)
return self._update(user, course_data, read_only=False, force_update_subsections=force_update_subsections)
@contextmanager
def _course_transaction(self, course_key):
"""
Provides a transaction context in which GradeResults are created.
"""
yield
VisibleBlocks.clear_cache(course_key)
def iter(
self,
users,
course=None,
collected_block_structure=None,
course_key=None,
force_update=False,
):
"""
Given a course and an iterable of students (User), yield a GradeResult
for every student enrolled in the course. GradeResult is a named tuple of:
(student, course_grade, err_msg)
If an error occurred, course_grade will be None and err_msg will be an
exception message. If there was no error, err_msg is an empty string.
"""
# Pre-fetch the collected course_structure so:
# 1. Correctness: the same version of the course is used to
# compute the grade for all students.
# 2. Optimization: the collected course_structure is not
# retrieved from the data store multiple times.
course_data = CourseData(
user=None, course=course, collected_block_structure=collected_block_structure, course_key=course_key,
)
stats_tags = [u'action:{}'.format(course_data.course_key)]
with self._course_transaction(course_data.course_key):
for user in users:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=stats_tags):
yield self._iter_grade_result(user, course_data, force_update)
def _iter_grade_result(self, user, course_data, force_update):
try:
kwargs = {
'user': user,
'course': course_data.course,
'collected_block_structure': course_data.collected_structure,
'course_key': course_data.course_key
}
if force_update:
kwargs['force_update_subsections'] = True
method = CourseGradeFactory().update if force_update else CourseGradeFactory().create
course_grade = method(**kwargs)
return self.GradeResult(user, course_grade, None)
except Exception as exc: # pylint: disable=broad-except
# Keep marching on even if this student couldn't be graded for
# some reason, but log it for future reference.
log.exception(
'Cannot grade student %s in course %s because of exception: %s',
user.id,
course_data.course_key,
exc.message
)
return self.GradeResult(user, None, exc)
@staticmethod
def _create_zero(user, course_data):
"""
Returns a ZeroCourseGrade object for the given user and course.
"""
log.debug(u'Grades: CreateZero, %s, User: %s', unicode(course_data), user.id)
return ZeroCourseGrade(user, course_data)
@staticmethod
def _read(user, course_data):
"""
Returns a CourseGrade object based on stored grade information
for the given user and course.
"""
if not should_persist_grades(course_data.course_key):
raise PersistentCourseGrade.DoesNotExist
persistent_grade = PersistentCourseGrade.read(user.id, course_data.course_key)
course_grade = CourseGrade(
user,
course_data,
persistent_grade.percent_grade,
persistent_grade.letter_grade,
persistent_grade.passed_timestamp is not None,
)
log.debug(u'Grades: Read, %s, User: %s, %s', unicode(course_data), user.id, persistent_grade)
return course_grade, persistent_grade.grading_policy_hash
@staticmethod
def _update(user, course_data, read_only, force_update_subsections=False):
"""
Computes, saves, and returns a CourseGrade object for the
given user and course.
Sends a COURSE_GRADE_CHANGED signal to listeners and a
COURSE_GRADE_NOW_PASSED if learner has passed course.
"""
course_grade = CourseGrade(user, course_data, force_update_subsections=force_update_subsections)
course_grade.update()
should_persist = (
(not read_only) and # TODO(TNL-6786) Remove the read_only boolean once all grades are back-filled.
should_persist_grades(course_data.course_key) and
course_grade.attempted
)
if should_persist:
course_grade._subsection_grade_factory.bulk_create_unsaved()
PersistentCourseGrade.update_or_create(
user_id=user.id,
course_id=course_data.course_key,
course_version=course_data.version,
course_edited_timestamp=course_data.edited_on,
grading_policy_hash=course_data.grading_policy_hash,
percent_grade=course_grade.percent,
letter_grade=course_grade.letter_grade or "",
passed=course_grade.passed,
)
COURSE_GRADE_CHANGED.send_robust(
sender=None,
user=user,
course_grade=course_grade,
course_key=course_data.course_key,
deadline=course_data.course.end,
)
if course_grade.passed:
COURSE_GRADE_NOW_PASSED.send(
sender=CourseGradeFactory,
user=user,
course_id=course_data.course_key,
)
log.info(
u'Grades: Update, %s, User: %s, %s, persisted: %s',
course_data.full_string(), user.id, course_grade, should_persist,
)
return course_grade