models.py 21.9 KB
Newer Older
1 2 3 4 5
"""
Models used for robust grading.

Robust grading allows student scores to be saved per-subsection independent
of any changes that may occur to the course after the score is achieved.
6 7 8
We also persist students' course-level grades, and update them whenever
a student's score or the course grading policy changes. As they are
persisted, course grades are also immune to changes in course content.
9 10 11 12 13 14
"""

from base64 import b64encode
from collections import namedtuple
from hashlib import sha1
import json
15
from lazy import lazy
16 17
import logging

18
from django.db import models
19
from django.utils.timezone import now
20
from eventtracking import tracker
21
from model_utils.models import TimeStampedModel
22
from track import contexts
23
from track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
24 25

from coursewarehistoryextended.fields import UnsignedBigIntAutoField
26
from opaque_keys.edx.keys import CourseKey, UsageKey
27
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField, UsageKeyField
28 29 30 31 32


log = logging.getLogger(__name__)


33 34
BLOCK_RECORD_LIST_VERSION = 1

35 36
# Used to serialize information about a block at the time it was used in
# grade calculation.
37
BlockRecord = namedtuple('BlockRecord', ['locator', 'weight', 'raw_possible', 'graded'])
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
class DeleteGradesMixin(object):
    """
    A Mixin class that provides functionality to delete grades.
    """

    @classmethod
    def query_grades(cls, course_ids=None, modified_start=None, modified_end=None):
        """
        Queries all the grades in the table, filtered by the provided arguments.
        """
        kwargs = {}

        if course_ids:
            kwargs['course_id__in'] = [course_id for course_id in course_ids]

        if modified_start:
            if modified_end:
                kwargs['modified__range'] = (modified_start, modified_end)
            else:
                kwargs['modified__gt'] = modified_start

        return cls.objects.filter(**kwargs)

    @classmethod
    def delete_grades(cls, *args, **kwargs):
        """
        Deletes all the grades in the table, filtered by the provided arguments.
        """
        query = cls.query_grades(*args, **kwargs)
        query.delete()


72
class BlockRecordList(tuple):
73
    """
74
    An immutable ordered list of BlockRecord objects.
75 76
    """

77
    def __new__(cls, blocks, course_key, version=None):  # pylint: disable=unused-argument
78
        return super(BlockRecordList, cls).__new__(cls, blocks)
79

80
    def __init__(self, blocks, course_key, version=None):
81
        super(BlockRecordList, self).__init__(blocks)
82
        self.course_key = course_key
83
        self.version = version or BLOCK_RECORD_LIST_VERSION
84

85 86 87 88 89
    def __eq__(self, other):
        assert isinstance(other, BlockRecordList)
        return hash(self) == hash(other)

    def __hash__(self):
90
        """
91 92
        Returns an integer Type value of the hash of this
        list of block records, as required by python.
93
        """
94 95 96 97 98 99 100 101 102 103 104 105 106
        return hash(self.hash_value)

    @lazy
    def hash_value(self):
        """
        Returns a hash value of the list of block records.

        This currently hashes using sha1, and returns a base64 encoded version
        of the binary digest.  In the future, different algorithms could be
        supported by adding a label indicated which algorithm was used, e.g.,
        "sha256$j0NDRmSPa5bfid2pAcUXaxCm2Dlh3TwayItZstwyeqQ=".
        """
        return b64encode(sha1(self.json_value).digest())
107

108 109
    @lazy
    def json_value(self):
110 111 112 113
        """
        Return a JSON-serialized version of the list of block records, using a
        stable ordering.
        """
114 115 116 117
        list_of_block_dicts = [block._asdict() for block in self]
        for block_dict in list_of_block_dicts:
            block_dict['locator'] = unicode(block_dict['locator'])  # BlockUsageLocator is not json-serializable
        data = {
118 119 120
            u'blocks': list_of_block_dicts,
            u'course_key': unicode(self.course_key),
            u'version': self.version,
121 122 123 124 125 126
        }
        return json.dumps(
            data,
            separators=(',', ':'),  # Remove spaces from separators for more compact representation
            sort_keys=True,
        )
127 128 129 130

    @classmethod
    def from_json(cls, blockrecord_json):
        """
131
        Return a BlockRecordList from previously serialized json.
132
        """
133
        data = json.loads(blockrecord_json)
134
        course_key = CourseKey.from_string(data['course_key'])
135
        block_dicts = data['blocks']
136 137
        record_generator = (
            BlockRecord(
138
                locator=UsageKey.from_string(block["locator"]).replace(course_key=course_key),
139
                weight=block["weight"],
140 141
                raw_possible=block["raw_possible"],
                graded=block["graded"],
142 143 144
            )
            for block in block_dicts
        )
145
        return cls(record_generator, course_key, version=data['version'])
146

147
    @classmethod
148
    def from_list(cls, blocks, course_key):
149
        """
150
        Return a BlockRecordList from the given list and course_key.
151
        """
152
        return cls(blocks, course_key)
153 154 155 156 157 158 159 160 161 162 163


class VisibleBlocksQuerySet(models.QuerySet):
    """
    A custom QuerySet representing VisibleBlocks.
    """

    def create_from_blockrecords(self, blocks):
        """
        Creates a new VisibleBlocks model object.

164
        Argument 'blocks' should be a BlockRecordList.
165
        """
166 167
        model, _ = self.get_or_create(
            hashed=blocks.hash_value,
168
            defaults={u'blocks_json': blocks.json_value, u'course_id': blocks.course_key},
169
        )
170 171 172 173 174 175 176 177
        return model


class VisibleBlocks(models.Model):
    """
    A django model used to track the state of a set of visible blocks under a
    given subsection at the time they are used for grade calculation.

178
    This state is represented using an array of BlockRecord, stored
179 180 181 182 183
    in the blocks_json field. A hash of this json array is used for lookup
    purposes.
    """
    blocks_json = models.TextField()
    hashed = models.CharField(max_length=100, unique=True)
184
    course_id = CourseKeyField(blank=False, max_length=255, db_index=True)
185 186 187

    objects = VisibleBlocksQuerySet.as_manager()

188 189 190
    class Meta(object):
        app_label = "grades"

191 192 193 194 195 196 197 198 199 200 201 202
    def __unicode__(self):
        """
        String representation of this model.
        """
        return u"VisibleBlocks object - hash:{}, raw json:'{}'".format(self.hashed, self.blocks_json)

    @property
    def blocks(self):
        """
        Returns the blocks_json data stored on this model as a list of
        BlockRecords in the order they were provided.
        """
203
        return BlockRecordList.from_json(self.blocks_json)
204

205 206
    @classmethod
    def bulk_read(cls, course_key):
207
        """
208
        Reads all visible block records for the given course.
209 210

        Arguments:
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
            course_key: The course identifier for the desired records
        """
        return cls.objects.filter(course_id=course_key)

    @classmethod
    def bulk_create(cls, block_record_lists):
        """
        Bulk creates VisibleBlocks for the given iterator of
        BlockRecordList objects.
        """
        return cls.objects.bulk_create([
            VisibleBlocks(
                blocks_json=brl.json_value,
                hashed=brl.hash_value,
                course_id=brl.course_key,
            )
            for brl in block_record_lists
        ])

    @classmethod
    def bulk_get_or_create(cls, block_record_lists, course_key):
        """
        Bulk creates VisibleBlocks for the given iterator of
        BlockRecordList objects for the given course_key, but
        only for those that aren't already created.
        """
        existent_records = {record.hashed: record for record in cls.bulk_read(course_key)}
        non_existent_brls = {brl for brl in block_record_lists if brl.hash_value not in existent_records}
        cls.bulk_create(non_existent_brls)
240 241


242
class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
243 244 245 246 247
    """
    A django model tracking persistent grades at the subsection level.
    """

    class Meta(object):
248
        app_label = "grades"
249 250 251 252 253 254
        unique_together = [
            # * Specific grades can be pulled using all three columns,
            # * Progress page can pull all grades for a given (course_id, user_id)
            # * Course staff can see all grades for a course using (course_id,)
            ('course_id', 'user_id', 'usage_key'),
        ]
255 256 257 258 259 260 261 262 263 264 265
        # Allows querying in the following ways:
        # (modified): find all the grades updated within a certain timespan
        # (modified, course_id): find all the grades updated within a timespan for a certain course
        # (modified, course_id, usage_key): find all the grades updated within a timespan for a subsection
        #   in a course
        # (first_attempted, course_id, user_id): find all attempted subsections in a course for a user
        # (first_attempted, course_id): find all attempted subsections in a course for all users
        index_together = [
            ('modified', 'course_id', 'usage_key'),
            ('first_attempted', 'course_id', 'user_id')
        ]
266 267 268 269 270 271

    # primary key will need to be large for this table
    id = UnsignedBigIntAutoField(primary_key=True)  # pylint: disable=invalid-name

    user_id = models.IntegerField(blank=False)
    course_id = CourseKeyField(blank=False, max_length=255)
272 273 274 275

    # note: the usage_key may not have the run filled in for
    # old mongo courses.  Use the full_usage_key property
    # instead when you want to use/compare the usage_key.
276 277 278
    usage_key = UsageKeyField(blank=False, max_length=255)

    # Information relating to the state of content when grade was calculated
279 280
    subtree_edited_timestamp = models.DateTimeField(u'Last content edit timestamp', blank=True, null=True)
    course_version = models.CharField(u'Guid of latest course version', blank=True, max_length=255)
281 282 283 284 285 286 287 288

    # earned/possible refers to the number of points achieved and available to achieve.
    # graded refers to the subset of all problems that are marked as being graded.
    earned_all = models.FloatField(blank=False)
    possible_all = models.FloatField(blank=False)
    earned_graded = models.FloatField(blank=False)
    possible_graded = models.FloatField(blank=False)

289 290 291 292 293
    # timestamp for the learner's first attempt at content in
    # this subsection. If null, indicates no attempt
    # has yet been made.
    first_attempted = models.DateTimeField(null=True, blank=True)

294 295 296
    # track which blocks were visible at the time of grade calculation
    visible_blocks = models.ForeignKey(VisibleBlocks, db_column='visible_blocks_hash', to_field='hashed')

297 298 299 300 301 302 303 304 305
    @property
    def full_usage_key(self):
        """
        Returns the "correct" usage key value with the run filled in.
        """
        if self.usage_key.run is None:  # pylint: disable=no-member
            return self.usage_key.replace(course_key=self.course_id)
        else:
            return self.usage_key
306 307 308 309 310

    def __unicode__(self):
        """
        Returns a string representation of this model.
        """
311 312 313
        return (
            u"{} user: {}, course version: {}, subsection: {} ({}). {}/{} graded, {}/{} all, first_attempted: {}"
        ).format(
314 315 316 317
            type(self).__name__,
            self.user_id,
            self.course_version,
            self.usage_key,
318
            self.visible_blocks_id,
319 320 321 322
            self.earned_graded,
            self.possible_graded,
            self.earned_all,
            self.possible_all,
323
            self.first_attempted,
324 325 326 327 328 329 330 331 332 333 334 335 336
        )

    @classmethod
    def read_grade(cls, user_id, usage_key):
        """
        Reads a grade from database

        Arguments:
            user_id: The user associated with the desired grade
            usage_key: The location of the subsection associated with the desired grade

        Raises PersistentSubsectionGrade.DoesNotExist if applicable
        """
337
        return cls.objects.select_related('visible_blocks').get(
338 339 340 341 342 343
            user_id=user_id,
            course_id=usage_key.course_key,  # course_id is included to take advantage of db indexes
            usage_key=usage_key,
        )

    @classmethod
344 345 346 347 348 349 350 351 352
    def bulk_read_grades(cls, user_id, course_key):
        """
        Reads all grades for the given user and course.

        Arguments:
            user_id: The user associated with the desired grades
            course_key: The course identifier for the desired grades
        """
        return cls.objects.select_related('visible_blocks').filter(
353
            user_id=user_id,
354
            course_id=course_key,
355 356
        )

357
    @classmethod
358
    def update_or_create_grade(cls, **params):
359 360 361
        """
        Wrapper for objects.update_or_create.
        """
362 363 364 365 366 367 368 369 370 371 372 373
        cls._prepare_params_and_visible_blocks(params)

        user_id = params.pop('user_id')
        usage_key = params.pop('usage_key')
        attempted = params.pop('attempted')

        grade, _ = cls.objects.update_or_create(
            user_id=user_id,
            course_id=usage_key.course_key,
            usage_key=usage_key,
            defaults=params,
        )
374

375 376 377
        if attempted and not grade.first_attempted:
            grade.first_attempted = now()
            grade.save()
378
        cls._emit_grade_calculated_event(grade)
379 380 381
        return grade

    @classmethod
382
    def create_grade(cls, **params):
383 384 385
        """
        Wrapper for objects.create.
        """
386 387
        cls._prepare_params_and_visible_blocks(params)
        cls._prepare_attempted_for_create(params, now())
388
        grade = cls.objects.create(**params)
389
        cls._emit_grade_calculated_event(grade)
390
        return grade
391 392 393 394 395 396 397 398 399 400 401 402

    @classmethod
    def bulk_create_grades(cls, grade_params_iter, course_key):
        """
        Bulk creation of grades.
        """
        if not grade_params_iter:
            return

        map(cls._prepare_params, grade_params_iter)
        VisibleBlocks.bulk_get_or_create([params['visible_blocks'] for params in grade_params_iter], course_key)
        map(cls._prepare_params_visible_blocks_id, grade_params_iter)
403 404
        first_attempt_timestamp = now()
        for params in grade_params_iter:
405 406
            cls._prepare_attempted_for_create(params, first_attempt_timestamp)
        grades = [PersistentSubsectionGrade(**params) for params in grade_params_iter]
407 408 409 410
        grades = cls.objects.bulk_create(grades)
        for grade in grades:
            cls._emit_grade_calculated_event(grade)
        return grades
411

412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431
    @classmethod
    def _prepare_params_and_visible_blocks(cls, params):
        """
        Prepares the fields for the grade record, while
        creating the related VisibleBlocks, if needed.
        """
        cls._prepare_params(params)
        params['visible_blocks'] = VisibleBlocks.objects.create_from_blockrecords(params['visible_blocks'])

    @classmethod
    def _prepare_params(cls, params):
        """
        Prepares the fields for the grade record.
        """
        if not params.get('course_id', None):
            params['course_id'] = params['usage_key'].course_key
        params['course_version'] = params.get('course_version', None) or ""
        params['visible_blocks'] = BlockRecordList.from_list(params['visible_blocks'], params['course_id'])

    @classmethod
432 433 434 435 436 437 438 439 440
    def _prepare_attempted_for_create(cls, params, timestamp):
        """
        When creating objects, an attempted subsection gets its timestamp set
        unconditionally.
        """
        if params.pop('attempted'):
            params['first_attempted'] = timestamp

    @classmethod
441 442 443 444 445 446 447 448 449 450 451
    def _prepare_params_visible_blocks_id(cls, params):
        """
        Prepares the visible_blocks_id field for the grade record,
        using the hash of the visible_blocks field.  Specifying
        the hashed field eliminates extra queries to get the
        VisibleBlocks record.  Use this variation of preparing
        the params when you are sure of the existence of the
        VisibleBlock.
        """
        params['visible_blocks_id'] = params['visible_blocks'].hash_value
        del params['visible_blocks']
452

453 454 455 456 457 458
    @staticmethod
    def _emit_grade_calculated_event(grade):
        """
        Emits an edx.grades.subsection.grade_calculated event
        with data from the passed grade.
        """
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
        # TODO: remove this context manager after completion of AN-6134
        event_name = u'edx.grades.subsection.grade_calculated'
        context = contexts.course_context_from_course_id(grade.course_id)
        with tracker.get_tracker().context(event_name, context):
            tracker.emit(
                event_name,
                {
                    'user_id': unicode(grade.user_id),
                    'course_id': unicode(grade.course_id),
                    'block_id': unicode(grade.usage_key),
                    'course_version': unicode(grade.course_version),
                    'weighted_total_earned': grade.earned_all,
                    'weighted_total_possible': grade.possible_all,
                    'weighted_graded_earned': grade.earned_graded,
                    'weighted_graded_possible': grade.possible_graded,
                    'first_attempted': unicode(grade.first_attempted),
                    'subtree_edited_timestamp': unicode(grade.subtree_edited_timestamp),
                    'event_transaction_id': unicode(get_event_transaction_id()),
                    'event_transaction_type': unicode(get_event_transaction_type()),
                    'visible_blocks_hash': unicode(grade.visible_blocks_id),
                }
            )
481

482

483
class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
484 485 486 487 488
    """
    A django model tracking persistent course grades.
    """

    class Meta(object):
489
        app_label = "grades"
490 491 492 493
        # Indices:
        # (course_id, user_id) for individual grades
        # (course_id) for instructors to see all course grades, implicitly created via the unique_together constraint
        # (user_id) for course dashboard; explicitly declared as an index below
494
        # (passed_timestamp, course_id) for tracking when users first earned a passing grade.
495 496
        # (modified): find all the grades updated within a certain timespan
        # (modified, course_id): find all the grades updated within a certain timespan for a course
497 498 499
        unique_together = [
            ('course_id', 'user_id'),
        ]
500 501
        index_together = [
            ('passed_timestamp', 'course_id'),
502
            ('modified', 'course_id')
503
        ]
504 505 506 507 508 509 510

    # primary key will need to be large for this table
    id = UnsignedBigIntAutoField(primary_key=True)  # pylint: disable=invalid-name
    user_id = models.IntegerField(blank=False, db_index=True)
    course_id = CourseKeyField(blank=False, max_length=255)

    # Information relating to the state of content when grade was calculated
511
    course_edited_timestamp = models.DateTimeField(u'Last content edit timestamp', blank=True, null=True)
512 513 514 515 516 517 518
    course_version = models.CharField(u'Course content version identifier', blank=True, max_length=255)
    grading_policy_hash = models.CharField(u'Hash of grading policy', blank=False, max_length=255)

    # Information about the course grade itself
    percent_grade = models.FloatField(blank=False)
    letter_grade = models.CharField(u'Letter grade for course', blank=False, max_length=255)

519 520 521
    # Information related to course completion
    passed_timestamp = models.DateTimeField(u'Date learner earned a passing grade', blank=True, null=True)

522 523 524 525
    def __unicode__(self):
        """
        Returns a string representation of this model.
        """
526 527 528 529 530 531
        return u', '.join([
            u"{} user: {}".format(type(self).__name__, self.user_id),
            u"course version: {}".format(self.course_version),
            u"grading policy: {}".format(self.grading_policy_hash),
            u"percent grade: {}%".format(self.percent_grade),
            u"letter grade: {}".format(self.letter_grade),
532
            u"passed timestamp: {}".format(self.passed_timestamp),
533
        ])
534 535 536 537 538 539 540 541 542 543 544 545 546 547 548

    @classmethod
    def read_course_grade(cls, user_id, course_id):
        """
        Reads a grade from database

        Arguments:
            user_id: The user associated with the desired grade
            course_id: The id of the course associated with the desired grade

        Raises PersistentCourseGrade.DoesNotExist if applicable
        """
        return cls.objects.get(user_id=user_id, course_id=course_id)

    @classmethod
549
    def update_or_create_course_grade(cls, user_id, course_id, **kwargs):
550 551 552 553
        """
        Creates a course grade in the database.
        Returns a PersistedCourseGrade object.
        """
554
        passed = kwargs.pop('passed')
555

556 557
        if kwargs.get('course_version', None) is None:
            kwargs['course_version'] = ""
558 559 560 561 562 563

        grade, _ = cls.objects.update_or_create(
            user_id=user_id,
            course_id=course_id,
            defaults=kwargs
        )
564 565 566
        if passed and not grade.passed_timestamp:
            grade.passed_timestamp = now()
            grade.save()
567
        cls._emit_grade_calculated_event(grade)
568
        return grade
569 570 571 572 573 574 575

    @staticmethod
    def _emit_grade_calculated_event(grade):
        """
        Emits an edx.grades.course.grade_calculated event
        with data from the passed grade.
        """
576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593
        # TODO: remove this context manager after completion of AN-6134
        event_name = u'edx.grades.course.grade_calculated'
        context = contexts.course_context_from_course_id(grade.course_id)
        with tracker.get_tracker().context(event_name, context):
            tracker.emit(
                event_name,
                {
                    'user_id': unicode(grade.user_id),
                    'course_id': unicode(grade.course_id),
                    'course_version': unicode(grade.course_version),
                    'percent_grade': grade.percent_grade,
                    'letter_grade': unicode(grade.letter_grade),
                    'course_edited_timestamp': unicode(grade.course_edited_timestamp),
                    'event_transaction_id': unicode(get_event_transaction_id()),
                    'event_transaction_type': unicode(get_event_transaction_type()),
                    'grading_policy_hash': unicode(grade.grading_policy_hash),
                }
            )