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

Calen Pennington committed
11 12
from courseware.model_data import FieldDataCache, DjangoKeyValueStore
from xblock.fields 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 42
            if module is None:
                return []
43
            return module.get_child_descriptors()
44 45
        else:
            return descriptor.get_children()
Calen Pennington committed
46

47
    stack = [descriptor]
48 49 50

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

54

55 56 57 58 59 60 61 62
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

63 64 65 66 67 68
    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 = []
69
    for _, sections in grading_context['graded_sections'].iteritems():
70 71 72 73 74 75
        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']:
76 77
                if moduledescriptor.location.url() in existing_student_modules:
                    sections_to_list.append(section_descriptor)
78 79
                    break

Calen Pennington committed
80
    field_data_cache = FieldDataCache(sections_to_list, course.id, student)
81 82
    for section_descriptor in sections_to_list:
        section_module = get_module(student, request,
Calen Pennington committed
83
                                    section_descriptor.location, field_data_cache,
84 85 86 87 88 89 90
                                    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
91

92 93 94
        for problem in yield_module_descendents(section_module):
            if isinstance(problem, CapaModule):
                yield problem
95

Calen Pennington committed
96

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
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:
116 117
                # Answer can be a list or some other unhashable element.  Convert to string.
                answer = str(capa_module.lcp.student_answers[problem_id])
118
                key = (capa_module.url_name, capa_module.display_name_with_default, problem_id)
119 120 121 122 123
                counts[key][answer] += 1

    return counts


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

Victor Shnayder committed
130 131
    course: a CourseDescriptor

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

140
    More information on the format is in the docstring for CourseGrader.
141
    """
142

143
    grading_context = course.grading_context
144
    raw_scores = []
145

Calen Pennington committed
146 147
    if field_data_cache is None:
        field_data_cache = FieldDataCache(grading_context['all_descriptors'], course.id, student)
148

149
    totaled_scores = {}
150 151 152
    # 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():
153 154 155
        format_scores = []
        for section in sections:
            section_descriptor = section['section_descriptor']
156
            section_name = section_descriptor.display_name_with_default
157

158
            should_grade_section = False
Calen Pennington committed
159
            # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
160
            for moduledescriptor in section['xmoduledescriptors']:
161 162 163 164 165 166
                # 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

Calen Pennington committed
167
                # Create a fake key to pull out a StudentModule object from the FieldDataCache
168

Calen Pennington committed
169
                key = DjangoKeyValueStore.Key(
170
                    Scope.user_state,
171 172 173 174
                    student.id,
                    moduledescriptor.location,
                    None
                )
Calen Pennington committed
175
                if field_data_cache.find(key):
176 177
                    should_grade_section = True
                    break
178

179 180
            if should_grade_section:
                scores = []
Calen Pennington committed
181

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

188
                for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module):
Calen Pennington committed
189

Calen Pennington committed
190
                    (correct, total) = get_score(course.id, student, module_descriptor, create_module, field_data_cache)
191 192
                    if correct is None and total is None:
                        continue
193

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

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

205
                    scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default))
206

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

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

220
        totaled_scores[section_format] = format_scores
221

222
    grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES)
223

224 225 226
    # 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
227

228 229
    letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
    grade_summary['grade'] = letter_grade
Calen Pennington committed
230
    grade_summary['totaled_scores'] = totaled_scores  	# make this available, eg for instructor download & debugging
231 232 233
    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
234
    return grade_summary
235

Calen Pennington committed
236

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

241 242 243 244 245
    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
    """
246

247
    letter_grade = None
Calen Pennington committed
248

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

    return letter_grade
257

258 259 260 261

# 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).
Calen Pennington committed
262
def progress_summary(student, request, course, field_data_cache):
263
    """
264
    This pulls a summary of all problems in the course.
265

266
    Returns
267 268 269 270
    - 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,
271
    etc.
272

273 274
    Arguments:
        student: A User object for the student to grade
275
        course: A Descriptor containing the course to grade
Calen Pennington committed
276
        field_data_cache: A FieldDataCache initialized with all
277
             instance_modules for the student
Calen Pennington committed
278

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

282
    """
Calen Pennington committed
283

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

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

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

304
            # Same for sections
Calen Pennington committed
305
            graded = section_module.graded
306
            scores = []
Calen Pennington committed
307

308
            module_creator = section_module.xmodule_runtime.get_module
Calen Pennington committed
309

310
            for module_descriptor in yield_dynamic_descriptor_descendents(section_module, module_creator):
Calen Pennington committed
311

312
                course_id = course.id
Calen Pennington committed
313
                (correct, total) = get_score(course_id, student, module_descriptor, module_creator, field_data_cache)
314 315 316
                if correct is None and total is None:
                    continue

317
                scores.append(Score(correct, total, graded, module_descriptor.display_name_with_default))
318

319
            scores.reverse()
320
            section_total, _ = graders.aggregate_scores(
321
                scores, section_module.display_name_with_default)
322

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

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

339
    return chapters
340 341


Calen Pennington committed
342
def get_score(course_id, user, problem_descriptor, module_creator, field_data_cache):
343
    """
344
    Return the score for a user on a problem, as a tuple (correct, total).
345 346 347 348
    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).
349 350

    user: a Student object
351 352 353
    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.
Calen Pennington committed
354
    cache: A FieldDataCache
355
    """
356 357 358
    if not user.is_authenticated():
        return (None, None)

359 360
    # 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
361 362
    if problem_descriptor.always_recalculate_grades:
        problem = module_creator(problem_descriptor)
363 364
        if problem is None:
            return (None, None)
365 366 367
        score = problem.get_score()
        if score is not None:
            return (score['score'], score['total'])
Victor Shnayder committed
368 369 370
        else:
            return (None, None)

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

375
    # Create a fake KeyValueStore key to pull out the StudentModule
Calen Pennington committed
376
    key = DjangoKeyValueStore.Key(
377
        Scope.user_state,
378 379 380 381 382
        user.id,
        problem_descriptor.location,
        None
    )

Calen Pennington committed
383
    student_module = field_data_cache.find(key)
384

385 386 387
    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
388
    else:
389 390
        # If the problem was not in the cache, or hasn't been graded yet,
        # we need to instantiate the problem.
391
        # Otherwise, the max score (cached in student_module) won't be available
392
        problem = module_creator(problem_descriptor)
393 394
        if problem is None:
            return (None, None)
395

396
        correct = 0.0
397 398
        total = problem.max_score()

399 400 401 402 403
        # 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)

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

    return (correct, total)