Files
edx-platform/lms/djangoapps/grades/new/course_grade.py
2016-11-30 12:36:26 -05:00

419 lines
16 KiB
Python

"""
CourseGrade Class
"""
from collections import defaultdict, namedtuple, OrderedDict
from logging import getLogger
from django.conf import settings
from django.core.exceptions import PermissionDenied
import dogstats_wrapper as dog_stats_api
from lazy import lazy
from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
from xmodule import block_metadata_utils
from ..models import PersistentCourseGrade
from .subsection_grade import SubsectionGradeFactory
from ..transformer import GradesTransformer
log = getLogger(__name__)
class CourseGrade(object):
"""
Course Grade class
"""
def __init__(self, student, course, course_structure):
self.student = student
self.course = course
self.course_version = getattr(course, 'course_version', None)
self.course_edited_timestamp = getattr(course, 'subtree_edited_on', None)
self.course_structure = course_structure
self._percent = None
self._letter_grade = None
self._subsection_grade_factory = SubsectionGradeFactory(self.student, self.course, self.course_structure)
@lazy
def graded_subsections_by_format(self):
"""
Returns grades for the subsections in the course in
a dict keyed by subsection format types.
"""
subsections_by_format = defaultdict(OrderedDict)
for chapter in self.chapter_grades:
for subsection_grade in chapter['sections']:
if subsection_grade.graded:
graded_total = subsection_grade.graded_total
if graded_total.possible > 0:
subsections_by_format[subsection_grade.format][subsection_grade.location] = subsection_grade
return subsections_by_format
@lazy
def locations_to_scores(self):
"""
Returns a dict of problem scores keyed by their locations.
"""
locations_to_scores = {}
for chapter in self.chapter_grades:
for subsection_grade in chapter['sections']:
locations_to_scores.update(subsection_grade.locations_to_scores)
return locations_to_scores
@lazy
def grade_value(self):
"""
Helper function to extract the grade value as calculated by the course's grader.
"""
# Grading policy might be overriden by a CCX, need to reset it
self.course.set_grading_policy(self.course.grading_policy)
grade_value = self.course.grader.grade(
self.graded_subsections_by_format,
generate_random_scores=settings.GENERATE_PROFILE_SCORES
)
# can't use the existing properties due to recursion issues caused by referencing self.grade_value
percent = self._calc_percent(grade_value)
letter_grade = self._compute_letter_grade(percent)
self._log_event(log.warning, u"grade_value, percent: {0}, grade: {1}".format(percent, letter_grade))
return grade_value
@lazy
def chapter_grades(self):
"""
Returns a list of chapters, each containing its subsection grades,
display name, and url name.
"""
chapter_grades = []
for chapter_key in self.course_structure.get_children(self.course.location):
chapter = self.course_structure[chapter_key]
chapter_subsection_grades = []
children = self.course_structure.get_children(chapter_key)
for subsection_key in children:
chapter_subsection_grades.append(
self._subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True)
)
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': chapter_subsection_grades
})
return chapter_grades
@property
def percent(self):
"""
Returns a rounded percent from the overall grade.
"""
if self._percent is None:
self._percent = self._calc_percent(self.grade_value)
return self._percent
@property
def letter_grade(self):
"""
Returns a letter representing the grade.
"""
if self._letter_grade is None:
self._letter_grade = self._compute_letter_grade(self.percent)
return self._letter_grade
@property
def passed(self):
"""
Check user's course passing status. Return True if passed.
"""
nonzero_cutoffs = [cutoff for cutoff in self.course.grade_cutoffs.values() if cutoff > 0]
success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None
return success_cutoff and self.percent >= success_cutoff
@property
def summary(self):
"""
Returns the grade summary as calculated by the course's grader.
"""
grade_summary = self.grade_value
grade_summary['percent'] = self.percent
grade_summary['grade'] = self.letter_grade
return grade_summary
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.
"""
subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades)
total_graded_subsections = sum(len(x) for x in self.graded_subsections_by_format.itervalues())
subsections_created = len(self._subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access
subsections_read = subsections_total - subsections_created
blocks_total = len(self.locations_to_scores)
if not read_only:
self._subsection_grade_factory.bulk_create_unsaved()
grading_policy_hash = self.get_grading_policy_hash(self.course.location, self.course_structure)
PersistentCourseGrade.update_or_create_course_grade(
user_id=self.student.id,
course_id=self.course.id,
course_version=self.course_version,
course_edited_timestamp=self.course_edited_timestamp,
grading_policy_hash=grading_policy_hash,
percent_grade=self.percent,
letter_grade=self.letter_grade or "",
passed=self.passed,
)
self._signal_listeners_when_grade_computed()
self._log_event(
log.warning,
u"compute_and_update, read_only: {0}, subsections read/created: {1}/{2}, blocks accessed: {3}, total "
u"graded subsections: {4}".format(
read_only,
subsections_read,
subsections_created,
blocks_total,
total_graded_subsections,
)
)
def score_for_module(self, location):
"""
Calculate the aggregate weighted score for any location in the course.
This method returns a tuple containing (earned_score, possible_score).
If the location is of 'problem' type, this method will return the
possible and earned scores for that problem. If the location refers to a
composite module (a vertical or section ) the scores will be the sums of
all scored problems that are children of the chosen location.
"""
if location in self.locations_to_scores:
score = self.locations_to_scores[location]
return score.earned, score.possible
children = self.course_structure.get_children(location)
earned = 0.0
possible = 0.0
for child in children:
child_earned, child_possible = self.score_for_module(child)
earned += child_earned
possible += child_possible
return earned, possible
@staticmethod
def get_grading_policy_hash(course_location, course_structure):
"""
Gets the grading policy of the course at the given location
in the given course structure.
"""
return course_structure.get_transformer_block_field(
course_location,
GradesTransformer,
'grading_policy_hash'
)
@classmethod
def load_persisted_grade(cls, user, course, course_structure):
"""
Initializes a CourseGrade object, filling its members with persisted values from the database.
If the grading policy is out of date, recomputes the grade.
If no persisted values are found, returns None.
"""
try:
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course.id)
except PersistentCourseGrade.DoesNotExist:
return None
course_grade = CourseGrade(user, course, course_structure)
current_grading_policy_hash = course_grade.get_grading_policy_hash(course.location, course_structure)
if current_grading_policy_hash != persistent_grade.grading_policy_hash:
return None
else:
course_grade._percent = persistent_grade.percent_grade # pylint: disable=protected-access
course_grade._letter_grade = persistent_grade.letter_grade # pylint: disable=protected-access
course_grade.course_version = persistent_grade.course_version
course_grade.course_edited_timestamp = persistent_grade.course_edited_timestamp
course_grade._log_event(log.info, u"load_persisted_grade") # pylint: disable=protected-access
return course_grade
@classmethod
def get_persisted_grade(cls, user, course):
"""
Gets the persisted grade in the database, without checking
whether it is up-to-date with the course's grading policy.
For read use only.
"""
try:
persistent_grade = PersistentCourseGrade.read_course_grade(user.id, course.id)
except PersistentCourseGrade.DoesNotExist:
return None
else:
course_grade = CourseGrade(user, course, None) # no course structure needed
course_grade._percent = persistent_grade.percent_grade # pylint: disable=protected-access
course_grade._letter_grade = persistent_grade.letter_grade # pylint: disable=protected-access
course_grade.course_version = persistent_grade.course_version
course_grade.course_edited_timestamp = persistent_grade.course_edited_timestamp
return course_grade
@staticmethod
def _calc_percent(grade_value):
"""
Helper for percent calculation.
"""
return round(grade_value['percent'] * 100 + 0.05) / 100
def _compute_letter_grade(self, percentage):
"""
Returns a letter grade as defined in grading_policy (e.g. 'A' 'B' 'C' for 6.002x) or None.
Arguments
- grade_cutoffs is a dictionary mapping a grade to the lowest
possible percentage to earn that grade.
- percentage is the final percent across all problems in a course
"""
letter_grade = None
grade_cutoffs = self.course.grade_cutoffs
# Possible grades, sorted in descending order of score
descending_grades = sorted(grade_cutoffs, key=lambda x: grade_cutoffs[x], reverse=True)
for possible_grade in descending_grades:
if percentage >= grade_cutoffs[possible_grade]:
letter_grade = possible_grade
break
return letter_grade
def _signal_listeners_when_grade_computed(self):
"""
Signal all listeners when grades are computed.
"""
responses = COURSE_GRADE_CHANGED.send_robust(
sender=None,
user=self.student,
course_grade=self,
course_key=self.course.id,
deadline=self.course.end
)
for receiver, response in responses:
log.debug(
'Signal fired when student grade is calculated. Receiver: %s. Response: %s',
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):
"""
Factory class to create Course Grade objects
"""
def create(self, student, course, read_only=True):
"""
Returns the CourseGrade object for the given student and course.
If read_only is True, doesn't save any updates to the grades.
Raises a PermissionDenied if the user does not have course access.
"""
course_structure = get_course_blocks(student, course.location)
# if user does not have access to this course, throw an exception
if not self._user_has_access_to_course(course_structure):
raise PermissionDenied("User does not have access to this course")
return (
self._get_saved_grade(student, course, course_structure) or
self._compute_and_update_grade(student, course, course_structure, read_only)
)
GradeResult = namedtuple('GradeResult', ['student', 'course_grade', 'err_msg'])
def iter(self, course, students):
"""
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.
"""
for student in students:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]):
try:
course_grade = CourseGradeFactory().create(student, course)
yield self.GradeResult(student, course_grade, "")
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 (%s) in course %s because of exception: %s',
student.username,
student.id,
course.id,
exc.message
)
yield self.GradeResult(student, None, exc.message)
def update(self, student, course, course_structure):
"""
Updates the CourseGrade for this Factory's student.
"""
self._compute_and_update_grade(student, course, course_structure)
def get_persisted(self, student, course):
"""
Returns the saved grade for the given course and student,
irrespective of whether the saved grade is up-to-date.
"""
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return None
return CourseGrade.get_persisted_grade(student, course)
def _get_saved_grade(self, student, course, course_structure):
"""
Returns the saved grade for the given course and student.
"""
if not PersistentGradesEnabledFlag.feature_enabled(course.id):
return None
return CourseGrade.load_persisted_grade(
student,
course,
course_structure
)
def _compute_and_update_grade(self, student, course, course_structure, read_only=False):
"""
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(student, course, course_structure)
course_grade.compute_and_update(read_only)
return course_grade
def _user_has_access_to_course(self, course_structure):
"""
Given a course structure, returns whether the user
for whom that course structure was retrieved
has access to the course.
"""
return len(course_structure) > 0