scores.py 10.3 KB
Newer Older
1 2 3
"""
Functionality for problem scores.
"""
4 5
from logging import getLogger

6
from xblock.core import XBlock
7 8

from openedx.core.lib.cache_utils import memoized
9
from xmodule.graders import ProblemScore
10

11
from .transformer import GradesTransformer
12

13 14 15
log = getLogger(__name__)


16
def possibly_scored(usage_key):
17
    """
18 19
    Returns whether the given block could impact grading (i.e.
    has_score or has_children).
20
    """
21
    return usage_key.block_type in _block_types_possibly_scored()
22

23

24
def get_score(submissions_scores, csm_scores, persisted_block, block):
25
    """
26 27 28 29 30 31 32 33 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 63 64 65 66 67 68 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
    Returns the score for a problem, as a ProblemScore object.  It is
    assumed that the provided storages have already been filtered for
    a single user in question and have user-specific values.

    The score is retrieved from the provided storages in the following
    order of precedence.  If no value for the block is found in a
    given storage, the next storage is checked.

    submissions_scores (dict of {unicode(usage_key): (earned, possible)}):

        A python dictionary of serialized UsageKeys to (earned, possible)
        tuples. These values, retrieved using the Submissions API by the
        caller (already filtered for the user and course), take precedence
        above all other score storages.

        When the score is found in this storage, it implies the user's score
        for the block was persisted via the submissions API. Typically, this API
        is used by ORA.

        The returned score includes valid values for:
            weighted_earned
            weighted_possible
            graded - retrieved from the persisted block, if found, else from
                the latest block content.

        Note: raw_earned and raw_possible are not required when submitting scores
        via the submissions API, so those values (along with the unused weight)
        are invalid and irrelevant.

    csm_scores (ScoresClient):

        The ScoresClient object (already filtered for the user and course),
        from which a courseware.models.StudentModule object can be retrieved for
        the block.

        When the score is found from this storage, it implies the user's score
        for the block was persisted in the Courseware Student Module. Typically,
        this storage is used for all CAPA problems, including scores calculated
        by external graders.

        The returned score includes valid values for:
            raw_earned, raw_possible - retrieved from CSM
            weighted_earned, weighted_possible - calculated from the raw scores and weight
            weight, graded - retrieved from the persisted block, if found,
                else from the latest block content

    persisted_block (.models.BlockRecord):
        The block values as found in the grades persistence layer. These values
        are used only if not found from an earlier storage, and take precedence
        over values stored within the latest content-version of the block.

        When the score is found from this storage, it implies the user has not
        yet attempted this problem, but the user's grade _was_ persisted.

        The returned score includes valid values for:
            raw_earned - will equal 0.0 since the user's score was not found from
                earlier storages
            raw_possible - retrieved from the persisted block
            weighted_earned, weighted_possible - calculated from the raw scores and weight
            weight, graded - retrieved from the persisted block

    block (block_structure.BlockData):
        Values from the latest content-version of the block are used only if
        they were not available from a prior storage.

        When the score is found from this storage, it implies the user has not
        yet attempted this problem and the user's grade was _not_ yet persisted.

        The returned score includes valid values for:
            raw_earned - will equal 0.0 since the user's score was not found from
                earlier storages
            raw_possible - retrieved from the latest block content
            weighted_earned, weighted_possible - calculated from the raw scores and weight
            weight, graded - retrieved from the latest block content
100
    """
101 102 103 104
    weight = _get_weight_from_block(persisted_block, block)

    # Priority order for retrieving the scores:
    # submissions API -> CSM -> grades persisted block -> latest block content
105
    raw_earned, raw_possible, weighted_earned, weighted_possible, first_attempted = (
106 107 108 109 110
        _get_score_from_submissions(submissions_scores, block) or
        _get_score_from_csm(csm_scores, block, weight) or
        _get_score_from_persisted_or_latest_block(persisted_block, block, weight)
    )

111 112 113 114 115 116 117 118 119 120 121 122 123 124
    if weighted_possible is None or weighted_earned is None:
        return None

    else:
        has_valid_denominator = weighted_possible > 0.0
        graded = _get_graded_from_block(persisted_block, block) if has_valid_denominator else False

        return ProblemScore(
            raw_earned,
            raw_possible,
            weighted_earned,
            weighted_possible,
            weight,
            graded,
125
            first_attempted=first_attempted,
126
        )
127 128


129
def weighted_score(raw_earned, raw_possible, weight):
130
    """
131
    Returns a tuple that represents the weighted (earned, possible) score.
132
    If weight is None or raw_possible is 0, returns the original values.
133 134 135 136

    When weight is used, it defines the weighted_possible.  This allows
    course authors to specify the exact maximum value for a problem when
    they provide a weight.
137
    """
138 139 140 141 142 143
    assert raw_possible is not None
    cannot_compute_with_weight = weight is None or raw_possible == 0
    if cannot_compute_with_weight:
        return raw_earned, raw_possible
    else:
        return float(raw_earned) * weight / raw_possible, float(weight)
144 145


146
def _get_score_from_submissions(submissions_scores, block):
147
    """
148 149 150 151 152
    Returns the score values from the submissions API if found.
    """
    if submissions_scores:
        submission_value = submissions_scores.get(unicode(block.location))
        if submission_value:
153 154 155
            first_attempted = submission_value['created_at']
            weighted_earned = submission_value['points_earned']
            weighted_possible = submission_value['points_possible']
156
            assert weighted_earned >= 0.0 and weighted_possible > 0.0  # per contract from submissions API
157
            return (None, None) + (weighted_earned, weighted_possible) + (first_attempted,)
158 159


160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
def _get_score_from_csm(csm_scores, block, weight):
    """
    Returns the score values from the courseware student module, via
    ScoresClient, if found.
    """
    # If an entry exists and has raw_possible (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.
    #
    # Note: Storing raw_possible in CSM predates the implementation of the grades
    # own persistence layer. Hence, we have duplicate storage locations for
    # raw_possible, with potentially conflicting values, when a problem is
    # attempted. Even though the CSM persistence for this value is now
    # superfluous, for backward compatibility, we continue to use its value for
    # raw_possible, giving it precedence over the one in the grades data model.
    score = csm_scores.get(block.location)
    has_valid_score = score and score.total is not None
    if has_valid_score:
179
        if score.correct is not None:
180
            first_attempted = score.created
181 182
            raw_earned = score.correct
        else:
183
            first_attempted = None
184
            raw_earned = 0.0
185

186
        raw_possible = score.total
187
        return (raw_earned, raw_possible) + weighted_score(raw_earned, raw_possible, weight) + (first_attempted,)
188 189 190


def _get_score_from_persisted_or_latest_block(persisted_block, block, weight):
191
    """
192 193 194 195 196 197
    Returns the score values, now assuming the earned score is 0.0 - since a
    score was not found in an earlier storage.
    Uses the raw_possible value from the persisted_block if found, else from
    the latest block content.
    """
    raw_earned = 0.0
198
    first_attempted = None
199

200 201 202 203
    if persisted_block:
        raw_possible = persisted_block.raw_possible
    else:
        raw_possible = block.transformer_data[GradesTransformer].max_score
204

205
    # TODO TNL-5982 remove defensive code for scorables without max_score
206
    if raw_possible is None:
207
        weighted_scores = (None, None)
208
    else:
209 210
        weighted_scores = weighted_score(raw_earned, raw_possible, weight)

211
    return (raw_earned, raw_possible) + weighted_scores + (first_attempted,)
212

213

214 215 216 217 218 219 220
def _get_weight_from_block(persisted_block, block):
    """
    Returns the weighted value from the persisted_block if found, else from
    the latest block content.
    """
    if persisted_block:
        return persisted_block.weight
221
    else:
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
        return getattr(block, 'weight', None)


def _get_graded_from_block(persisted_block, block):
    """
    Returns the graded value from the persisted_block if found, else from
    the latest block content.
    """
    if persisted_block:
        return persisted_block.graded
    else:
        return _get_explicit_graded(block)


def _get_explicit_graded(block):
    """
    Returns the explicit graded field value for the given block.
    """
    field_value = getattr(
        block.transformer_data[GradesTransformer],
        GradesTransformer.EXPLICIT_GRADED_FIELD_NAME,
        None,
    )

    # Set to True if grading is not explicitly disabled for
    # this block.  This allows us to include the block's score
    # in the aggregated self.graded_total, regardless of the
    # inherited graded value from the subsection. (TNL-5560)
    return True if field_value is None else field_value


@memoized
def _block_types_possibly_scored():
    """
    Returns the block types that could have a score.

    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 frozenset(
        category for (category, xblock_class) in XBlock.load_classes() if (
            getattr(xblock_class, 'has_score', False) or getattr(xblock_class, 'has_children', False)
        )
    )