grades.py 16.2 KB
Newer Older
1 2 3
# Compute grades using real division, with no integer truncation
from __future__ import division

4
import random
5
import logging
6

7
from collections import defaultdict
8
from django.conf import settings
9
from django.contrib.auth.models import User
10

11 12
from .model_data import ModelDataCache, LmsKeyValueStore
from xblock.core import Scope
13
from .module_render import get_module, get_module_for_descriptor
14
from xmodule import graders
15
from xmodule.capa_module import CapaModule
16
from xmodule.graders import Score
17
from .models import StudentModule
18

19
log = logging.getLogger("mitx.courseware")
20

Calen Pennington committed
21

22
def yield_module_descendents(module):
23
    stack = module.get_display_items()
24
    stack.reverse()
25

26 27
    while len(stack) > 0:
        next_module = stack.pop()
Calen Pennington committed
28
        stack.extend(next_module.get_display_items())
29
        yield next_module
30

Calen Pennington committed
31

32 33 34 35 36 37 38 39 40
def yield_dynamic_descriptor_descendents(descriptor, module_creator):
    """
    This returns all of the descendants of a descriptor. If the descriptor
    has dynamic children, the module will be created using module_creator
    and the children (as descriptors) of that module will be returned.
    """
    def get_dynamic_descriptor_children(descriptor):
        if descriptor.has_dynamic_children():
            module = module_creator(descriptor)
41
            return module.get_child_descriptors()
42 43
        else:
            return descriptor.get_children()
Calen Pennington committed
44

45
    stack = [descriptor]
46 47 48

    while len(stack) > 0:
        next_descriptor = stack.pop()
Calen Pennington committed
49
        stack.extend(get_dynamic_descriptor_children(next_descriptor))
50
        yield next_descriptor
Calen Pennington committed
51

52

53 54 55 56 57 58 59 60
def yield_problems(request, course, student):
    """
    Return an iterator over capa_modules that this student has
    potentially answered.  (all that student has answered will definitely be in
    the list, but there may be others as well).
    """
    grading_context = course.grading_context

61 62 63 64 65 66
    descriptor_locations = (descriptor.location.url() for descriptor in grading_context['all_descriptors'])
    existing_student_modules = set(StudentModule.objects.filter(
        module_state_key__in=descriptor_locations
    ).values_list('module_state_key', flat=True))

    sections_to_list = []
67
    for _, sections in grading_context['graded_sections'].iteritems():
68 69 70 71 72 73
        for section in sections:

            section_descriptor = section['section_descriptor']

            # If the student hasn't seen a single problem in the section, skip it.
            for moduledescriptor in section['xmoduledescriptors']:
74 75
                if moduledescriptor.location.url() in existing_student_modules:
                    sections_to_list.append(section_descriptor)
76 77
                    break

78 79 80 81 82 83 84 85 86 87 88
    model_data_cache = ModelDataCache(sections_to_list, course.id, student)
    for section_descriptor in sections_to_list:
        section_module = get_module(student, request,
                                    section_descriptor.location, model_data_cache,
                                    course.id)
        if section_module is None:
            # student doesn't have access to this module, or something else
            # went wrong.
            # log.debug("couldn't get module for student {0} for section location {1}"
            #           .format(student.username, section_descriptor.location))
            continue
89

90 91 92
        for problem in yield_module_descendents(section_module):
            if isinstance(problem, CapaModule):
                yield problem
93

Calen Pennington committed
94

95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113
def answer_distributions(request, course):
    """
    Given a course_descriptor, compute frequencies of answers for each problem:

    Format is:

    dict: (problem url_name, problem display_name, problem_id) -> (dict : answer ->  count)

    TODO (vshnayder): this is currently doing a full linear pass through all
    students and all problems.  This will be just a little slow.
    """

    counts = defaultdict(lambda: defaultdict(int))

    enrolled_students = User.objects.filter(courseenrollment__course_id=course.id)

    for student in enrolled_students:
        for capa_module in yield_problems(request, course, student):
            for problem_id in capa_module.lcp.student_answers:
114 115
                # Answer can be a list or some other unhashable element.  Convert to string.
                answer = str(capa_module.lcp.student_answers[problem_id])
116
                key = (capa_module.url_name, capa_module.display_name_with_default, problem_id)
117 118 119 120 121
                counts[key][answer] += 1

    return counts


122
def grade(student, request, course, model_data_cache=None, keep_raw_scores=False):
123
    """
124
    This grades a student as quickly as possible. It returns the
125 126
    output from the course grader, augmented with the final letter
    grade. The keys in the output are:
127

Victor Shnayder committed
128 129
    course: a CourseDescriptor

130 131 132 133 134 135
    - 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)
136
    - keep_raw_scores : if True, then value for key 'raw_scores' contains scores for every graded module
137

138
    More information on the format is in the docstring for CourseGrader.
139
    """
140

141
    grading_context = course.grading_context
142
    raw_scores = []
143

144
    if model_data_cache is None:
145
        model_data_cache = ModelDataCache(grading_context['all_descriptors'], course.id, student)
146

147
    totaled_scores = {}
148 149 150
    # This next complicated loop is just to collect the totaled_scores, which is
    # passed to the grader
    for section_format, sections in grading_context['graded_sections'].iteritems():
151 152 153
        format_scores = []
        for section in sections:
            section_descriptor = section['section_descriptor']
154
            section_name = section_descriptor.display_name_with_default
155

156
            should_grade_section = False
Calen Pennington committed
157
            # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
158
            for moduledescriptor in section['xmoduledescriptors']:
159 160 161 162 163 164
                # some problems have state that is updated independently of interaction
                # with the LMS, so they need to always be scored. (E.g. foldit.)
                if moduledescriptor.always_recalculate_grades:
                    should_grade_section = True
                    break

165
                # Create a fake key to pull out a StudentModule object from the ModelDataCache
166

167
                key = LmsKeyValueStore.Key(
168
                    Scope.user_state,
169 170 171 172 173
                    student.id,
                    moduledescriptor.location,
                    None
                )
                if model_data_cache.find(key):
174 175
                    should_grade_section = True
                    break
176

177 178
            if should_grade_section:
                scores = []
Calen Pennington committed
179

180
                def create_module(descriptor):
181 182
                    '''creates an XModule instance given a descriptor'''
                    # TODO: We need the request to pass into here. If we could forego that, our arguments
183
                    # would be simpler
184
                    return get_module_for_descriptor(student, request, descriptor, model_data_cache, course.id)
Calen Pennington committed
185

186
                for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module):
Calen Pennington committed
187

188
                    (correct, total) = get_score(course.id, student, module_descriptor, create_module, model_data_cache)
189 190
                    if correct is None and total is None:
                        continue
191

Calen Pennington committed
192
                    if settings.GENERATE_PROFILE_SCORES:  	# for debugging!
193 194 195 196
                        if total > 1:
                            correct = random.randrange(max(total - 2, 1), total + 1)
                        else:
                            correct = total
197

198
                    graded = module_descriptor.lms.graded
199 200 201
                    if not total > 0:
                        #We simply cannot grade a problem that is 12/0, because we might need it as a percentage
                        graded = False
202

203
                    scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default))
204

205
                _, graded_total = graders.aggregate_scores(scores, section_name)
206 207
                if keep_raw_scores:
                    raw_scores += scores
208
            else:
209
                graded_total = Score(0.0, 1.0, True, section_name)
210

211 212 213
            #Add the graded total to totaled_scores
            if graded_total.possible > 0:
                format_scores.append(graded_total)
214
            else:
215 216
                log.exception("Unable to grade a section with a total possible score of zero. " +
                              str(section_descriptor.location))
217

218
        totaled_scores[section_format] = format_scores
219

220
    grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES)
221

222 223 224
    # We round the grade here, to make sure that the grade is an whole percentage and
    # doesn't get displayed differently than it gets grades
    grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
225

226 227
    letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
    grade_summary['grade'] = letter_grade
Calen Pennington committed
228
    grade_summary['totaled_scores'] = totaled_scores  	# make this available, eg for instructor download & debugging
229 230 231
    if keep_raw_scores:
        grade_summary['raw_scores'] = raw_scores        # way to get all RAW scores out to instructor
                                                        # so grader can be double-checked
232
    return grade_summary
233

Calen Pennington committed
234

235 236
def grade_for_percentage(grade_cutoffs, percentage):
    """
kimth committed
237
    Returns a letter grade as defined in grading_policy (e.g. 'A' 'B' 'C' for 6.002x) or None.
238

239 240 241 242 243
    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
    """
244

245
    letter_grade = None
Calen Pennington committed
246

kimth committed
247 248 249
    # 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:
250 251 252
        if percentage >= grade_cutoffs[possible_grade]:
            letter_grade = possible_grade
            break
253 254

    return letter_grade
255

256 257 258 259

# TODO: This method is not very good. It was written in the old course style and
# then converted over and performance is not good. Once the progress page is redesigned
# to not have the progress summary this method should be deleted (so it won't be copied).
260
def progress_summary(student, request, course, model_data_cache):
261
    """
262
    This pulls a summary of all problems in the course.
263

264
    Returns
265 266 267 268
    - courseware_summary is a summary of all sections with problems in the course.
    It is organized as an array of chapters, each containing an array of sections,
    each containing an array of scores. This contains information for graded and
    ungraded problems, and is good for displaying a course summary with due dates,
269
    etc.
270

271 272
    Arguments:
        student: A User object for the student to grade
273
        course: A Descriptor containing the course to grade
274
        model_data_cache: A ModelDataCache initialized with all
275
             instance_modules for the student
Calen Pennington committed
276

277 278
    If the student does not have access to load the course module, this function
    will return None.
Calen Pennington committed
279

280
    """
Calen Pennington committed
281

282
    # TODO: We need the request to pass into here. If we could forego that, our arguments
283
    # would be simpler
284
    course_module = get_module(student, request, course.location, model_data_cache, course.id, depth=None)
285 286 287
    if not course_module:
        # This student must not have access to the course.
        return None
Calen Pennington committed
288

289
    chapters = []
290
    # Don't include chapters that aren't displayable (e.g. due to error)
291
    for chapter_module in course_module.get_display_items():
292
        # Skip if the chapter is hidden
293
        if chapter_module.lms.hide_from_toc:
294
            continue
Calen Pennington committed
295

296
        sections = []
297
        for section_module in chapter_module.get_display_items():
298
            # Skip if the section is hidden
299
            if section_module.lms.hide_from_toc:
300
                continue
Calen Pennington committed
301

302
            # Same for sections
303
            graded = section_module.lms.graded
304
            scores = []
Calen Pennington committed
305

306
            module_creator = section_module.system.get_module
Calen Pennington committed
307

308
            for module_descriptor in yield_dynamic_descriptor_descendents(section_module.descriptor, module_creator):
Calen Pennington committed
309

310
                course_id = course.id
311
                (correct, total) = get_score(course_id, student, module_descriptor, module_creator, model_data_cache)
312 313 314
                if correct is None and total is None:
                    continue

315
                scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default))
316

317
            scores.reverse()
318
            section_total, _ = graders.aggregate_scores(
319
                scores, section_module.display_name_with_default)
320

321
            module_format = section_module.lms.format if section_module.lms.format is not None else ''
322
            sections.append({
323
                'display_name': section_module.display_name_with_default,
324
                'url_name': section_module.url_name,
325 326
                'scores': scores,
                'section_total': section_total,
327
                'format': module_format,
328
                'due': section_module.lms.due,
329 330 331
                'graded': graded,
            })

332 333
        chapters.append({'course': course.display_name_with_default,
                         'display_name': chapter_module.display_name_with_default,
334
                         'url_name': chapter_module.url_name,
335 336
                         'sections': sections})

337
    return chapters
338 339


340
def get_score(course_id, user, problem_descriptor, module_creator, model_data_cache):
341
    """
342
    Return the score for a user on a problem, as a tuple (correct, total).
343 344 345 346
    e.g. (5,7) if you got 5 out of 7 points.

    If this problem doesn't have a score, or we couldn't load it, returns (None,
    None).
347 348

    user: a Student object
349 350 351
    problem_descriptor: an XModuleDescriptor
    module_creator: a function that takes a descriptor, and returns the corresponding XModule for this user.
           Can return None if user doesn't have access, or if something else went wrong.
352
    cache: A ModelDataCache
353
    """
354 355 356
    if not user.is_authenticated():
        return (None, None)

357 358
    # some problems have state that is updated independently of interaction
    # with the LMS, so they need to always be scored. (E.g. foldit.)
Victor Shnayder committed
359 360
    if problem_descriptor.always_recalculate_grades:
        problem = module_creator(problem_descriptor)
361 362
        if problem is None:
            return (None, None)
363 364 365
        score = problem.get_score()
        if score is not None:
            return (score['score'], score['total'])
Victor Shnayder committed
366 367 368
        else:
            return (None, None)

369
    if not problem_descriptor.has_score:
370
        # These are not problems, and do not have a score
371
        return (None, None)
372

373 374
    # Create a fake KeyValueStore key to pull out the StudentModule
    key = LmsKeyValueStore.Key(
375
        Scope.user_state,
376 377 378 379 380 381
        user.id,
        problem_descriptor.location,
        None
    )

    student_module = model_data_cache.find(key)
382

383 384 385
    if student_module is not None and student_module.max_grade is not None:
        correct = student_module.grade if student_module.grade is not None else 0
        total = student_module.max_grade
386
    else:
387 388
        # If the problem was not in the cache, or hasn't been graded yet,
        # we need to instantiate the problem.
389
        # Otherwise, the max score (cached in student_module) won't be available
390
        problem = module_creator(problem_descriptor)
391 392
        if problem is None:
            return (None, None)
393

394
        correct = 0.0
395 396
        total = problem.max_score()

397 398 399 400 401
        # Problem may be an error module (if something in the problem builder failed)
        # In which case total might be None
        if total is None:
            return (None, None)

402
    # Now we re-weight the problem, if specified
403
    weight = problem_descriptor.weight
404 405
    if weight is not None:
        if total == 0:
406
            log.exception("Cannot reweight a problem with zero total points. Problem: " + str(student_module))
407 408 409
            return (correct, total)
        correct = correct * weight / total
        total = weight
410 411

    return (correct, total)