grades.py 33.9 KB
Newer Older
1 2
# Compute grades using real division, with no integer truncation
from __future__ import division
3
from collections import defaultdict
4
from functools import partial
5
import json
6
import random
7
import logging
8

9
from contextlib import contextmanager
10
from django.conf import settings
11
from django.test.client import RequestFactory
12
from django.core.cache import cache
13

14
import dogstats_wrapper as dog_stats_api
15

16
from courseware import courses
17
from courseware.access import has_access
18
from courseware.model_data import FieldDataCache, ScoresClient
Will Daly committed
19
from student.models import anonymous_id_for_user
20
from util.db import outer_atomic
21
from util.module_utils import yield_dynamic_descriptor_descendants
22 23
from xmodule import graders
from xmodule.graders import Score
24 25
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
26
from .models import StudentModule
27
from .module_render import get_module_for_descriptor
28
from opaque_keys import InvalidKeyError
29
from opaque_keys.edx.keys import CourseKey
30
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
31

32

33
log = logging.getLogger("edx.courseware")
Calen Pennington committed
34

35

36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
class MaxScoresCache(object):
    """
    A cache for unweighted max scores for problems.

    The key assumption here is that any problem that has not yet recorded a
    score for a user is worth the same number of points. An XBlock is free to
    score one student at 2/5 and another at 1/3. But a problem that has never
    issued a score -- say a problem two students have only seen mentioned in
    their progress pages and never interacted with -- should be worth the same
    number of points for everyone.
    """
    def __init__(self, cache_prefix):
        self.cache_prefix = cache_prefix
        self._max_scores_cache = {}
        self._max_scores_updates = {}

    @classmethod
    def create_for_course(cls, course):
        """
        Given a CourseDescriptor, return a correctly configured `MaxScoresCache`

        This method will base the `MaxScoresCache` cache prefix value on the
        last time something was published to the live version of the course.
        This is so that we don't have to worry about stale cached values for
        max scores -- any time a content change occurs, we change our cache
        keys.
        """
63 64 65 66 67 68
        if course.subtree_edited_on is None:
            # check for subtree_edited_on because old XML courses doesn't have this attribute
            cache_key = u"{}".format(course.id)
        else:
            cache_key = u"{}.{}".format(course.id, course.subtree_edited_on.isoformat())
        return cls(cache_key)
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 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 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129

    def fetch_from_remote(self, locations):
        """
        Populate the local cache with values from django's cache
        """
        remote_dict = cache.get_many([self._remote_cache_key(loc) for loc in locations])
        self._max_scores_cache = {
            self._local_cache_key(remote_key): value
            for remote_key, value in remote_dict.items()
            if value is not None
        }

    def push_to_remote(self):
        """
        Update the remote cache
        """
        if self._max_scores_updates:
            cache.set_many(
                {
                    self._remote_cache_key(key): value
                    for key, value in self._max_scores_updates.items()
                },
                60 * 60 * 24  # 1 day
            )

    def _remote_cache_key(self, location):
        """Convert a location to a remote cache key (add our prefixing)."""
        return u"grades.MaxScores.{}___{}".format(self.cache_prefix, unicode(location))

    def _local_cache_key(self, remote_key):
        """Convert a remote cache key to a local cache key (i.e. location str)."""
        return remote_key.split(u"___", 1)[1]

    def num_cached_from_remote(self):
        """How many items did we pull down from the remote cache?"""
        return len(self._max_scores_cache)

    def num_cached_updates(self):
        """How many local updates are we waiting to push to the remote cache?"""
        return len(self._max_scores_updates)

    def set(self, location, max_score):
        """
        Adds a max score to the max_score_cache
        """
        loc_str = unicode(location)
        if self._max_scores_cache.get(loc_str) != max_score:
            self._max_scores_updates[loc_str] = max_score

    def get(self, location):
        """
        Retrieve a max score from the cache
        """
        loc_str = unicode(location)
        max_score = self._max_scores_updates.get(loc_str)
        if max_score is None:
            max_score = self._max_scores_cache.get(loc_str)

        return max_score


130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
class ProgressSummary(object):
    """
    Wrapper class for the computation of a user's scores across a course.

    Attributes
       chapters: 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, etc.

       weighted_scores: a dictionary mapping module locations to weighted Score
       objects.

       locations_to_children: a dictionary mapping module locations to their
       direct descendants.
    """
    def __init__(self, chapters, weighted_scores, locations_to_children):
        self.chapters = chapters
        self.weighted_scores = weighted_scores
        self.locations_to_children = locations_to_children

    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.weighted_scores:
            score = self.weighted_scores[location]
            return score.earned, score.possible
        children = self.locations_to_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


175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
def descriptor_affects_grading(block_types_affecting_grading, descriptor):
    """
    Returns True if the descriptor could have any impact on grading, else False.

    Something might be a scored item if it is capable of storing a score
    (has_score=True). We also have to include anything that can have children,
    since those children might have scores. We can avoid things like Videos,
    which have state but cannot ever impact someone's grade.
    """
    return descriptor.location.block_type in block_types_affecting_grading


def field_data_cache_for_grading(course, user):
    """
    Given a CourseDescriptor and User, create the FieldDataCache for grading.

    This will generate a FieldDataCache that only loads state for those things
    that might possibly affect the grading process, and will ignore things like
    Videos.
    """
    descriptor_filter = partial(descriptor_affects_grading, course.block_types_affecting_grading)
    return FieldDataCache.cache_for_descriptor_descendents(
        course.id,
        user,
        course,
        depth=None,
        descriptor_filter=descriptor_filter
    )


205
def answer_distributions(course_key):
206
    """
207
    Given a course_key, return answer distributions in the form of a dictionary
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
    mapping:

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

    Answer distributions are found by iterating through all StudentModule
    entries for a given course with type="problem" and a grade that is not null.
    This means that we only count LoncapaProblems that people have submitted.
    Other types of items like ORA or sequences will not be collected. Empty
    Loncapa problem state that gets created from runnig the progress page is
    also not counted.

    This method accesses the StudentModule table directly instead of using the
    CapaModule abstraction. The main reason for this is so that we can generate
    the report without any side-effects -- we don't have to worry about answer
    distribution potentially causing re-evaluation of the student answer. This
    also allows us to use the read-replica database, which reduces risk of bad
    locking behavior. And quite frankly, it makes this a lot less confusing.

    Also, we're pulling all available records from the database for this course
    rather than crawling through a student's course-tree -- the latter could
    potentially cause us trouble with A/B testing. The distribution report may
    not be aware of problems that are not visible to the user being used to
    generate the report.

    This method will try to use a read-replica database if one is available.
233
    """
234 235 236
    # dict: { module.module_state_key : (url_name, display_name) }
    state_keys_to_problem_info = {}  # For caching, used by url_and_display_name

237
    def url_and_display_name(usage_key):
238
        """
239
        For a given usage_key, return the problem's url and display_name.
240
        Handle modulestore access and caching. This method ignores permissions.
241 242 243 244 245

        Raises:
            InvalidKeyError: if the usage_key does not parse
            ItemNotFoundError: if there is no content that corresponds
                to this usage_key.
246 247
        """
        problem_store = modulestore()
248 249
        if usage_key not in state_keys_to_problem_info:
            problem = problem_store.get_item(usage_key)
250
            problem_info = (problem.url_name, problem.display_name_with_default_escaped)
251
            state_keys_to_problem_info[usage_key] = problem_info
252

253
        return state_keys_to_problem_info[usage_key]
254 255 256 257

    # Iterate through all problems submitted for this course in no particular
    # order, and build up our answer_counts dict that we will eventually return
    answer_counts = defaultdict(lambda: defaultdict(int))
258
    for module in StudentModule.all_submitted_problems_read_only(course_key):
259 260 261 262 263
        try:
            state_dict = json.loads(module.state) if module.state else {}
            raw_answers = state_dict.get("student_answers", {})
        except ValueError:
            log.error(
264 265 266
                u"Answer Distribution: Could not parse module state for StudentModule id=%s, course=%s",
                module.id,
                course_key,
267
            )
268
            continue
269

270
        try:
271
            url, display_name = url_and_display_name(module.module_state_key.map_into_course(course_key))
272 273 274 275 276 277 278 279 280 281 282
            # Each problem part has an ID that is derived from the
            # module.module_state_key (with some suffix appended)
            for problem_part_id, raw_answer in raw_answers.items():
                # Convert whatever raw answers we have (numbers, unicode, None, etc.)
                # to be unicode values. Note that if we get a string, it's always
                # unicode and not str -- state comes from the json decoder, and that
                # always returns unicode for strings.
                answer = unicode(raw_answer)
                answer_counts[(url, display_name, problem_part_id)][answer] += 1

        except (ItemNotFoundError, InvalidKeyError):
283 284 285 286 287 288 289 290
            msg = (
                "Answer Distribution: Item {} referenced in StudentModule {} " +
                "for user {} in course {} not found; " +
                "This can happen if a student answered a question that " +
                "was later deleted from the course. This answer will be " +
                "omitted from the answer distribution CSV."
            ).format(
                module.module_state_key, module.id, module.student_id, course_key
291
            )
292
            log.warning(msg)
293
            continue
294 295

    return answer_counts
296

297

298
def grade(student, request, course, keep_raw_scores=False, field_data_cache=None, scores_client=None):
299
    """
300 301 302
    Returns the grade of the student.

    Also sends a signal to update the minimum grade requirement status.
303
    """
304 305 306 307 308 309 310 311
    grade_summary = _grade(student, request, course, keep_raw_scores, field_data_cache, scores_client)
    responses = GRADES_UPDATED.send_robust(
        sender=None,
        username=student.username,
        grade_summary=grade_summary,
        course_key=course.id,
        deadline=course.end
    )
312

313 314
    for receiver, response in responses:
        log.info('Signal fired when student grade is calculated. Receiver: %s. Response: %s', receiver, response)
315

316
    return grade_summary
317 318


319
def _grade(student, request, course, keep_raw_scores, field_data_cache, scores_client):
320 321 322
    """
    Unwrapped version of "grade"

323
    This grades a student as quickly as possible. It returns the
324 325
    output from the course grader, augmented with the final letter
    grade. The keys in the output are:
326

Victor Shnayder committed
327 328
    course: a CourseDescriptor

329 330 331
    - grade : A final letter grade.
    - percent : The final percent for the class (rounded up).
    - section_breakdown : A breakdown of each section that makes
332
      up the grade. (For display)
333
    - grade_breakdown : A breakdown of the major components that
334 335 336
      make up the final grade. (For display)
    - keep_raw_scores : if True, then value for key 'raw_scores' contains scores
      for every graded module
337

338
    More information on the format is in the docstring for CourseGrader.
339
    """
340 341
    with outer_atomic():
        if field_data_cache is None:
342
            field_data_cache = field_data_cache_for_grading(course, student)
343 344
        if scores_client is None:
            scores_client = ScoresClient.from_field_data_cache(field_data_cache)
345

Will Daly committed
346 347 348
    # 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)
349 350 351 352 353
    # 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

354 355 356 357 358 359 360 361 362 363 364 365
    with outer_atomic():
        submissions_scores = sub_api.get_scores(
            course.id.to_deprecated_string(),
            anonymous_id_for_user(student, course.id)
        )
        max_scores_cache = MaxScoresCache.create_for_course(course)

        # For the moment, we have to get scorable_locations from field_data_cache
        # and not from scores_client, because scores_client is ignorant of things
        # in the submissions API. As a further refactoring step, submissions should
        # be hidden behind the ScoresClient.
        max_scores_cache.fetch_from_remote(field_data_cache.scorable_locations)
366 367 368

    grading_context = course.grading_context
    raw_scores = []
Will Daly committed
369

370
    totaled_scores = {}
371 372 373
    # 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():
374 375 376
        format_scores = []
        for section in sections:
            section_descriptor = section['section_descriptor']
377
            section_name = section_descriptor.display_name_with_default_escaped
378

379 380
            with outer_atomic():
                # some problems have state that is updated independently of interaction
381
                # with the LMS, so they need to always be scored. (E.g. combinedopenended ORA1)
382 383 384
                # TODO This block is causing extra savepoints to be fired that are empty because no queries are executed
                # during the loop. When refactoring this code please keep this outer_atomic call in mind and ensure we
                # are not making unnecessary database queries.
Will Daly committed
385
                should_grade_section = any(
386
                    descriptor.always_recalculate_grades for descriptor in section['xmoduledescriptors']
Will Daly committed
387 388
                )

389 390 391 392 393 394 395
                # If there are no problems that always have to be regraded, 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.
                if not should_grade_section:
                    should_grade_section = any(
                        descriptor.location.to_deprecated_string() in submissions_scores
                        for descriptor in section['xmoduledescriptors']
396
                    )
Calen Pennington committed
397

398 399 400 401
                if not should_grade_section:
                    should_grade_section = any(
                        descriptor.location in scores_client
                        for descriptor in section['xmoduledescriptors']
Will Daly committed
402
                    )
403

404 405 406 407 408 409 410 411 412 413 414
                # 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 = []

                    def create_module(descriptor):
                        '''creates an XModule instance given a descriptor'''
                        # TODO: We need the request to pass into here. If we could forego that, our arguments
                        # would be simpler
                        return get_module_for_descriptor(
                            student, request, descriptor, field_data_cache, course.id, course=course
415
                        )
416

417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451
                    descendants = yield_dynamic_descriptor_descendants(section_descriptor, student.id, create_module)
                    for module_descriptor in descendants:
                        user_access = has_access(
                            student, 'load', module_descriptor, module_descriptor.location.course_key
                        )
                        if not user_access:
                            continue

                        (correct, total) = get_score(
                            student,
                            module_descriptor,
                            create_module,
                            scores_client,
                            submissions_scores,
                            max_scores_cache,
                        )
                        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 = module_descriptor.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,
452
                                module_descriptor.display_name_with_default_escaped,
453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470
                                module_descriptor.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_descriptor.location)
                    )
471

472
        totaled_scores[section_format] = format_scores
473

474 475 476 477
    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)
478

479 480 481
        # 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
482

483 484 485 486 487 488 489
        letter_grade = grade_for_percentage(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
490

491
        max_scores_cache.push_to_remote()
492

493
    return grade_summary
494

Calen Pennington committed
495

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

500 501 502 503 504
    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
    """
505

506
    letter_grade = None
Calen Pennington committed
507

kimth committed
508 509 510
    # 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:
511 512 513
        if percentage >= grade_cutoffs[possible_grade]:
            letter_grade = possible_grade
            break
514 515

    return letter_grade
516

517

518
def progress_summary(student, request, course, field_data_cache=None, scores_client=None):
519
    """
520
    Returns progress summary for all chapters in the course.
521
    """
522 523 524 525 526
    progress = _progress_summary(student, request, course, field_data_cache, scores_client)
    if progress:
        return progress.chapters
    else:
        return None
527 528 529 530 531 532 533


def get_weighted_scores(student, course, field_data_cache=None, scores_client=None):
    """
    Uses the _progress_summary method to return a ProgressSummmary object
    containing details of a students weighted scores for the course.
    """
534 535
    request = _get_mock_request(student)
    return _progress_summary(student, request, course, field_data_cache, scores_client)
536 537


538 539 540
# 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).
541
def _progress_summary(student, request, course, field_data_cache=None, scores_client=None):
542
    """
543 544
    Unwrapped version of "progress_summary".

545
    This pulls a summary of all problems in the course.
546

547
    Returns
548 549 550 551
    - 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,
552
    etc.
553

554 555
    Arguments:
        student: A User object for the student to grade
556
        course: A Descriptor containing the course to grade
Calen Pennington committed
557

558 559
    If the student does not have access to load the course module, this function
    will return None.
Calen Pennington committed
560

561
    """
562
    with outer_atomic():
563 564 565 566 567
        if field_data_cache is None:
            field_data_cache = field_data_cache_for_grading(course, student)
        if scores_client is None:
            scores_client = ScoresClient.from_field_data_cache(field_data_cache)

568 569 570
        course_module = get_module_for_descriptor(
            student, request, course, field_data_cache, course.id, course=course
        )
571 572
        if not course_module:
            return None
Calen Pennington committed
573

574 575
        course_module = getattr(course_module, '_x_module', course_module)

576 577 578 579
    # 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
580 581 582 583
    with outer_atomic():
        submissions_scores = sub_api.get_scores(
            course.id.to_deprecated_string(), anonymous_id_for_user(student, course.id)
        )
584

585 586 587 588 589 590
        max_scores_cache = MaxScoresCache.create_for_course(course)
        # For the moment, we have to get scorable_locations from field_data_cache
        # and not from scores_client, because scores_client is ignorant of things
        # in the submissions API. As a further refactoring step, submissions should
        # be hidden behind the ScoresClient.
        max_scores_cache.fetch_from_remote(field_data_cache.scorable_locations)
Will Daly committed
591

592
    chapters = []
593 594
    locations_to_children = defaultdict(list)
    locations_to_weighted_scores = {}
595
    # Don't include chapters that aren't displayable (e.g. due to error)
596
    for chapter_module in course_module.get_display_items():
597
        # Skip if the chapter is hidden
Calen Pennington committed
598
        if chapter_module.hide_from_toc:
599
            continue
Calen Pennington committed
600

601
        sections = []
602
        for section_module in chapter_module.get_display_items():
603
            # Skip if the section is hidden
604
            with outer_atomic():
605 606
                if section_module.hide_from_toc:
                    continue
Calen Pennington committed
607

608 609
                graded = section_module.graded
                scores = []
Calen Pennington committed
610

611
                module_creator = section_module.xmodule_runtime.get_module
612

613 614 615
                for module_descriptor in yield_dynamic_descriptor_descendants(
                        section_module, student.id, module_creator
                ):
616
                    locations_to_children[module_descriptor.parent].append(module_descriptor.location)
Will Daly committed
617
                    (correct, total) = get_score(
618 619 620 621 622 623
                        student,
                        module_descriptor,
                        module_creator,
                        scores_client,
                        submissions_scores,
                        max_scores_cache,
Will Daly committed
624
                    )
625 626
                    if correct is None and total is None:
                        continue
627

628 629 630 631
                    weighted_location_score = Score(
                        correct,
                        total,
                        graded,
632
                        module_descriptor.display_name_with_default_escaped,
633
                        module_descriptor.location
634
                    )
635

636 637 638
                    scores.append(weighted_location_score)
                    locations_to_weighted_scores[module_descriptor.location] = weighted_location_score

639 640
                scores.reverse()
                section_total, _ = graders.aggregate_scores(
641
                    scores, section_module.display_name_with_default_escaped)
642 643 644

                module_format = section_module.format if section_module.format is not None else ''
                sections.append({
645
                    'display_name': section_module.display_name_with_default_escaped,
646 647 648 649
                    'url_name': section_module.url_name,
                    'scores': scores,
                    'section_total': section_total,
                    'format': module_format,
650
                    'due': section_module.due,
651 652 653 654
                    'graded': graded,
                })

        chapters.append({
655 656
            'course': course.display_name_with_default_escaped,
            'display_name': chapter_module.display_name_with_default_escaped,
657 658 659
            'url_name': chapter_module.url_name,
            'sections': sections
        })
660

661 662
    max_scores_cache.push_to_remote()

663
    return ProgressSummary(chapters, locations_to_weighted_scores, locations_to_children)
664

Will Daly committed
665

666 667 668 669 670 671 672 673 674
def weighted_score(raw_correct, raw_total, weight):
    """Return a tuple that represents the weighted (correct, total) score."""
    # If there is no weighting, or weighting can't be applied, return input.
    if weight is None or raw_total == 0:
        return (raw_correct, raw_total)
    return (float(raw_correct) * weight / raw_total, float(weight))


def get_score(user, problem_descriptor, module_creator, scores_client, submissions_scores_cache, max_scores_cache):
675
    """
676
    Return the score for a user on a problem, as a tuple (correct, total).
677 678 679 680
    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).
681 682

    user: a Student object
683
    problem_descriptor: an XModuleDescriptor
684
    scores_client: an initialized ScoresClient
685 686
    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.
687
    submissions_scores_cache: A dict of location names to (earned, possible) point tuples.
Will Daly committed
688
           If an entry is found in this cache, it takes precedence.
689
    max_scores_cache: a MaxScoresCache
690
    """
691
    submissions_scores_cache = submissions_scores_cache or {}
Will Daly committed
692

693 694 695
    if not user.is_authenticated():
        return (None, None)

696
    location_url = problem_descriptor.location.to_deprecated_string()
697 698
    if location_url in submissions_scores_cache:
        return submissions_scores_cache[location_url]
Will Daly committed
699

700
    # some problems have state that is updated independently of interaction
701
    # with the LMS, so they need to always be scored. (E.g. combinedopenended ORA1.)
Victor Shnayder committed
702 703
    if problem_descriptor.always_recalculate_grades:
        problem = module_creator(problem_descriptor)
704 705
        if problem is None:
            return (None, None)
706 707 708
        score = problem.get_score()
        if score is not None:
            return (score['score'], score['total'])
Victor Shnayder committed
709 710 711
        else:
            return (None, None)

712
    if not problem_descriptor.has_score:
713
        # These are not problems, and do not have a score
714
        return (None, None)
715

716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731
    # Check the score that comes from the ScoresClient (out of CSM).
    # If an entry exists and has a total associated with it, we trust that
    # value. This is important for cases where a student might have seen an
    # older version of the problem -- they're still graded on what was possible
    # when they tried the problem, not what it's worth now.
    score = scores_client.get(problem_descriptor.location)
    cached_max_score = max_scores_cache.get(problem_descriptor.location)
    if score and score.total is not None:
        # We have a valid score, just use it.
        correct = score.correct if score.correct is not None else 0.0
        total = score.total
    elif cached_max_score is not None and settings.FEATURES.get("ENABLE_MAX_SCORE_CACHE"):
        # We don't have a valid score entry but we know from our cache what the
        # max possible score is, so they've earned 0.0 / cached_max_score
        correct = 0.0
        total = cached_max_score
732
    else:
733 734 735 736
        # This means we don't have a valid score entry and we don't have a
        # cached_max_score on hand. We know they've earned 0.0 points on this,
        # but we need to instantiate the module (i.e. load student state) in
        # order to find out how much it was worth.
737
        problem = module_creator(problem_descriptor)
738 739
        if problem is None:
            return (None, None)
740

741
        correct = 0.0
742 743
        total = problem.max_score()

744 745 746 747
        # 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)
748 749 750
        else:
            # add location to the max score cache
            max_scores_cache.set(problem_descriptor.location, total)
751

752
    return weighted_score(correct, total, problem_descriptor.weight)
753 754


755
def iterate_grades_for(course_or_id, students, keep_raw_scores=False):
756 757 758 759
    """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.

760
    If an error occurred, gradeset will be an empty dict and err_msg will be an
761 762 763 764 765 766 767 768 769 770
    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)
771
    - raw_scores: contains scores for every graded module
772
    """
773
    if isinstance(course_or_id, (basestring, CourseKey)):
774 775 776
        course = courses.get_course_by_id(course_or_id)
    else:
        course = course_or_id
777 778

    for student in students:
779
        with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course.id)]):
780
            try:
781
                request = _get_mock_request(student)
782 783 784 785 786
                # Grading calls problem rendering, which calls masquerading,
                # which checks session vars -- thus the empty session dict below.
                # It's not pretty, but untangling that is currently beyond the
                # scope of this feature.
                request.session = {}
787
                gradeset = grade(student, request, course, keep_raw_scores)
788
                yield student, gradeset, ""
789
            except Exception as exc:  # pylint: disable=broad-except
790
                # Keep marching on even if this student couldn't be graded for
791
                # some reason, but log it for future reference.
792 793 794 795
                log.exception(
                    'Cannot grade student %s (%s) in course %s because of exception: %s',
                    student.username,
                    student.id,
796
                    course.id,
797 798 799
                    exc.message
                )
                yield student, {}, exc.message
800 801 802 803 804 805 806 807 808 809 810


def _get_mock_request(student):
    """
    Make a fake request because grading code expects to be able to look at
    the request. We have to attach the correct user to the request before
    grading that student.
    """
    request = RequestFactory().get('/')
    request.user = student
    return request