grades.py 15.5 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
from models import StudentModuleCache
12
from module_render import get_module, get_instance_module
13
from xmodule import graders
14
from xmodule.capa_module import CapaModule
15
from xmodule.course_module import CourseDescriptor
16
from xmodule.graders import Score
17
from models import StudentModule
18

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

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

25 26 27 28
    while len(stack) > 0:
        next_module = stack.pop()
        stack.extend( next_module.get_display_items() )
        yield next_module
29

30 31 32 33 34 35 36 37 38
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)
39
            child_locations = module.get_children_locations()
40 41 42 43 44
            return [descriptor.system.load_item(child_location) for child_location in child_locations ]
        else:
            return descriptor.get_children()
    
    
45
    stack = [descriptor]
46 47 48 49 50 51 52

    while len(stack) > 0:
        next_descriptor = stack.pop()
        stack.extend( get_dynamic_descriptor_children(next_descriptor) )
        yield next_descriptor
    

53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
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
    student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])

    for section_format, sections in grading_context['graded_sections'].iteritems():
        for section in sections:

            section_descriptor = section['section_descriptor']

            # If the student hasn't seen a single problem in the section, skip it.
            skip = True
            for moduledescriptor in section['xmoduledescriptors']:
                if student_module_cache.lookup(
                        course.id, moduledescriptor.category, moduledescriptor.location.url()):
                    skip = False
                    break

            if skip:
                continue

            section_module = get_module(student, request,
                                        section_descriptor.location, student_module_cache,
                                        course.id)
            if section_module is None:
                # student doesn't have access to this module, or something else
                # went wrong.
84 85
                # log.debug("couldn't get module for student {0} for section location {1}"
                #           .format(student.username, section_descriptor.location))
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
                continue

            for problem in yield_module_descendents(section_module):
                if isinstance(problem, CapaModule):
                    yield problem

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:
111 112
                # Answer can be a list or some other unhashable element.  Convert to string.
                answer = str(capa_module.lcp.student_answers[problem_id])
113 114 115 116 117 118
                key = (capa_module.url_name, capa_module.display_name, problem_id)
                counts[key][answer] += 1

    return counts


119
def grade(student, request, course, student_module_cache=None, keep_raw_scores=False):
120
    """
121
    This grades a student as quickly as possible. It retuns the
122 123
    output from the course grader, augmented with the final letter
    grade. The keys in the output are:
124

Victor Shnayder committed
125 126
    course: a CourseDescriptor

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

135
    More information on the format is in the docstring for CourseGrader.
136
    """
137

138
    grading_context = course.grading_context
139
    raw_scores = []
140

141
    if student_module_cache == None:
142
        student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
143

144
    totaled_scores = {}
145 146 147
    # 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():
148 149 150 151
        format_scores = []
        for section in sections:
            section_descriptor = section['section_descriptor']
            section_name = section_descriptor.metadata.get('display_name')
152

153
            should_grade_section = False
154
            # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%  
155
            for moduledescriptor in section['xmoduledescriptors']:
156 157
                if student_module_cache.lookup(
                        course.id, moduledescriptor.category, moduledescriptor.location.url()):
158 159
                    should_grade_section = True
                    break
160

161 162
            if should_grade_section:
                scores = []
163
                
164 165 166 167 168 169
                def create_module(descriptor):
                    # TODO: We need the request to pass into here. If we could forgo that, our arguments
                    # would be simpler
                    return get_module(student, request, descriptor.location, 
                                        student_module_cache, course.id)
                                
170 171
                for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module):
                                                     
172
                    (correct, total) = get_score(course.id, student, module_descriptor, create_module, student_module_cache)
173 174
                    if correct is None and total is None:
                        continue
175

176
                    if settings.GENERATE_PROFILE_SCORES:	# for debugging!
177 178 179 180
                        if total > 1:
                            correct = random.randrange(max(total - 2, 1), total + 1)
                        else:
                            correct = total
181

182
                    graded = module_descriptor.metadata.get("graded", False)
183 184 185
                    if not total > 0:
                        #We simply cannot grade a problem that is 12/0, because we might need it as a percentage
                        graded = False
186

187
                    scores.append(Score(correct, total, graded, module_descriptor.metadata.get('display_name')))
188

189
                section_total, graded_total = graders.aggregate_scores(scores, section_name)
190 191
                if keep_raw_scores:
                    raw_scores += scores
192 193
            else:
                section_total = Score(0.0, 1.0, False, section_name)
194
                graded_total = Score(0.0, 1.0, True, section_name)
195

196 197 198
            #Add the graded total to totaled_scores
            if graded_total.possible > 0:
                format_scores.append(graded_total)
199
            else:
200
                log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.location))
201

202
        totaled_scores[section_format] = format_scores
203

204
    grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES)
205

206 207 208
    # 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
209

210 211
    letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
    grade_summary['grade'] = letter_grade
212 213 214 215
    grade_summary['totaled_scores'] = totaled_scores	# make this available, eg for instructor download & debugging
    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
216
    return grade_summary
217 218 219

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

222 223 224 225 226
    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
    """
227

228
    letter_grade = None
kimth committed
229 230 231 232
    
    # 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:
233 234 235
        if percentage >= grade_cutoffs[possible_grade]:
            letter_grade = possible_grade
            break
236 237

    return letter_grade
238

239 240 241 242

# 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).
243
def progress_summary(student, request, course, student_module_cache):
244
    """
245
    This pulls a summary of all problems in the course.
246

247
    Returns
248 249 250 251
    - 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,
252
    etc.
253

254 255
    Arguments:
        student: A User object for the student to grade
256
        course: A Descriptor containing the course to grade
257 258
        student_module_cache: A StudentModuleCache initialized with all
             instance_modules for the student
259 260 261 262
    
    If the student does not have access to load the course module, this function
    will return None.
    
263
    """
264
    
265 266 267 268 269 270
    
    # TODO: We need the request to pass into here. If we could forgo that, our arguments
    # would be simpler
    course_module = get_module(student, request,
                                course.location, student_module_cache,
                                course.id)
271 272 273
    if not course_module:
        # This student must not have access to the course.
        return None
274
    
275
    chapters = []
276
    # Don't include chapters that aren't displayable (e.g. due to error)
277
    for chapter_module in course_module.get_display_items():
278
        # Skip if the chapter is hidden
279
        hidden = chapter_module.metadata.get('hide_from_toc','false')
kimth committed
280
        if hidden.lower() == 'true':
281
            continue
282
        
283
        sections = []
284
        for section_module in chapter_module.get_display_items():
285
            # Skip if the section is hidden
286
            hidden = section_module.metadata.get('hide_from_toc','false')
kimth committed
287
            if hidden.lower() == 'true':
288
                continue
289
            
290
            # Same for sections
291
            graded = section_module.metadata.get('graded', False)
292
            scores = []
293
            
294 295 296
            module_creator = lambda descriptor : section_module.system.get_module(descriptor.location)
            
            for module_descriptor in yield_dynamic_descriptor_descendents(section_module.descriptor, module_creator):
297 298
                
                course_id = course.id
299
                (correct, total) = get_score(course_id, student, module_descriptor, module_creator, student_module_cache)
300 301 302
                if correct is None and total is None:
                    continue

303
                scores.append(Score(correct, total, graded,
304
                    module_descriptor.metadata.get('display_name')))
305

306
            scores.reverse()
307
            section_total, graded_total = graders.aggregate_scores(
308
                scores, section_module.metadata.get('display_name'))
309

310
            format = section_module.metadata.get('format', "")
311
            sections.append({
312 313
                'display_name': section_module.display_name,
                'url_name': section_module.url_name,
314 315 316
                'scores': scores,
                'section_total': section_total,
                'format': format,
317
                'due': section_module.metadata.get("due", ""),
318 319 320
                'graded': graded,
            })

321
        chapters.append({'course': course.display_name,
322 323
                         'display_name': chapter_module.display_name,
                         'url_name': chapter_module.url_name,
324 325
                         'sections': sections})

326
    return chapters
327 328


329
def get_score(course_id, user, problem_descriptor, module_creator, student_module_cache):
330
    """
331
    Return the score for a user on a problem, as a tuple (correct, total).
332 333 334 335
    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).
336 337

    user: a Student object
338 339 340
    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.
341
    cache: A StudentModuleCache
342
    """
343
    if not (problem_descriptor.stores_state and problem_descriptor.has_score):
344
        # These are not problems, and do not have a score
345
        return (None, None)
346

347
    correct = 0.0
348

349 350
    instance_module = student_module_cache.lookup(
        course_id, problem_descriptor.category, problem_descriptor.location.url())
351

352 353
    if not instance_module:
        # If the problem was not in the cache, we need to instantiate the problem.
354
        # Otherwise, the max score (cached in instance_module) won't be available
355
        problem = module_creator(problem_descriptor)
356 357
        if problem is None:
            return (None, None)
358
        instance_module = get_instance_module(course_id, user, problem, student_module_cache)
359 360

    # If this problem is ungraded/ungradable, bail
Bridger Maxwell committed
361
    if not instance_module or instance_module.max_grade is None:
362 363 364 365 366 367 368
        return (None, None)

    correct = instance_module.grade if instance_module.grade is not None else 0
    total = instance_module.max_grade

    if correct is not None and total is not None:
        #Now we re-weight the problem, if specified
369
        weight = getattr(problem_descriptor, 'weight', None)
370 371
        if weight is not None:
            if total == 0:
372
                log.exception("Cannot reweight a problem with zero total points. Problem: " + str(instance_module))
373
                return (correct, total)
374
            correct = correct * weight / total
375
            total = weight
376 377

    return (correct, total)