Files
edx-platform/lms/djangoapps/grades/course_grades.py
2016-07-22 00:09:43 -04:00

257 lines
10 KiB
Python

"""
Functionality for course-level grades.
"""
from logging import getLogger
from django.conf import settings
import dogstats_wrapper as dog_stats_api
import random
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from course_blocks.api import get_course_blocks
from courseware.courses import get_course_by_id
from courseware.model_data import ScoresClient
from student.models import anonymous_id_for_user
from util.db import outer_atomic
from xmodule import graders, block_metadata_utils
from xmodule.graders import Score
from .context import grading_context
from .scores import get_score
log = getLogger(__name__)
def iterate_grades_for(course_or_id, students, keep_raw_scores=False):
"""Given a course_id and an iterable of students (User), yield a tuple of:
(student, gradeset, err_msg) for every student enrolled in the course.
If an error occurred, gradeset will be an empty dict and err_msg will be an
exception message. If there was no error, err_msg is an empty string.
The gradeset is a dictionary with the following fields:
- grade : A final letter grade.
- percent : The final percent for the class (rounded up).
- section_breakdown : A breakdown of each section that makes
up the grade. (For display)
- grade_breakdown : A breakdown of the major components that
make up the final grade. (For display)
- raw_scores: contains scores for every graded module
"""
if isinstance(course_or_id, (basestring, CourseKey)):
course = get_course_by_id(course_or_id)
else:
course = course_or_id
for student in students:
with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course.id)]):
try:
gradeset = summary(student, course, keep_raw_scores)
yield student, gradeset, ""
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 student, {}, exc.message
def summary(student, course, keep_raw_scores=False, course_structure=None):
"""
Returns the grade summary of the student for the given course.
Also sends a signal to update the minimum grade requirement status.
"""
grade_summary = _summary(student, course, keep_raw_scores, course_structure)
responses = GRADES_UPDATED.send_robust(
sender=None,
username=student.username,
grade_summary=grade_summary,
course_key=course.id,
deadline=course.end
)
for receiver, response in responses:
log.info('Signal fired when student grade is calculated. Receiver: %s. Response: %s', receiver, response)
return grade_summary
def _summary(student, course, keep_raw_scores, course_structure=None):
"""
This grades a student as quickly as possible. It returns the
output from the course grader, augmented with the final letter
grade. The keys in the output are:
- course: a CourseDescriptor
- keep_raw_scores : if True, then value for key 'raw_scores' contains scores
for every graded module
More information on the format is in the docstring for CourseGrader.
"""
if course_structure is None:
course_structure = get_course_blocks(student, course.location)
grading_context_result = grading_context(course_structure)
scorable_locations = [block.location for block in grading_context_result['all_graded_blocks']]
with outer_atomic():
scores_client = ScoresClient.create_for_locations(course.id, student.id, scorable_locations)
# Dict of item_ids -> (earned, possible) point tuples. This *only* grabs
# scores that were registered with the submissions API, which for the moment
# means only openassessment (edx-ora2)
# We need to import this here to avoid a circular dependency of the form:
# XBlock --> submissions --> Django Rest Framework error strings -->
# Django translation --> ... --> courseware --> submissions
from submissions import api as sub_api # installed from the edx-submissions repository
with outer_atomic():
submissions_scores = sub_api.get_scores(
course.id.to_deprecated_string(),
anonymous_id_for_user(student, course.id)
)
totaled_scores, raw_scores = _calculate_totaled_scores(
student, grading_context_result, submissions_scores, scores_client, keep_raw_scores
)
with outer_atomic():
# Grading policy might be overriden by a CCX, need to reset it
course.set_grading_policy(course.grading_policy)
grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES)
# We round the grade here, to make sure that the grade is a whole percentage and
# doesn't get displayed differently than it gets grades
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
letter_grade = _letter_grade(course.grade_cutoffs, grade_summary['percent'])
grade_summary['grade'] = letter_grade
grade_summary['totaled_scores'] = totaled_scores # make this available, eg for instructor download & debugging
if keep_raw_scores:
# way to get all RAW scores out to instructor
# so grader can be double-checked
grade_summary['raw_scores'] = raw_scores
return grade_summary
def _calculate_totaled_scores(
student,
grading_context_result,
submissions_scores,
scores_client,
keep_raw_scores,
):
"""
Returns a tuple of totaled scores and raw scores, which can be passed to the grader.
"""
raw_scores = []
totaled_scores = {}
for section_format, sections in grading_context_result['all_graded_sections'].iteritems():
format_scores = []
for section_info in sections:
section = section_info['section_block']
section_name = block_metadata_utils.display_name_with_default(section)
with outer_atomic():
# Check to
# see if any of our locations are in the scores from the submissions
# API. If scores exist, we have to calculate grades for this section.
should_grade_section = any(
unicode(descendant.location) in submissions_scores
for descendant in section_info['scored_descendants']
)
if not should_grade_section:
should_grade_section = any(
descendant.location in scores_client
for descendant in section_info['scored_descendants']
)
# If we haven't seen a single problem in the section, we don't have
# to grade it at all! We can assume 0%
if should_grade_section:
scores = []
for descendant in section_info['scored_descendants']:
(correct, total) = get_score(
student,
descendant,
scores_client,
submissions_scores,
)
if correct is None and total is None:
continue
if settings.GENERATE_PROFILE_SCORES: # for debugging!
if total > 1:
correct = random.randrange(max(total - 2, 1), total + 1)
else:
correct = total
graded = descendant.graded
if not total > 0:
# We simply cannot grade a problem that is 12/0, because we might need it as a percentage
graded = False
scores.append(
Score(
correct,
total,
graded,
block_metadata_utils.display_name_with_default_escaped(descendant),
descendant.location
)
)
__, graded_total = graders.aggregate_scores(scores, section_name)
if keep_raw_scores:
raw_scores += scores
else:
graded_total = Score(0.0, 1.0, True, section_name, None)
# Add the graded total to totaled_scores
if graded_total.possible > 0:
format_scores.append(graded_total)
else:
log.info(
"Unable to grade a section with a total possible score of zero. " +
str(section.location)
)
totaled_scores[section_format] = format_scores
return totaled_scores, raw_scores
def _letter_grade(grade_cutoffs, 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
# 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