models.py 16.8 KB
Newer Older
1 2 3 4 5 6
"""
WE'RE USING MIGRATIONS!

If you make changes to this model, be sure to create an appropriate migration
file and check it in at the same time as your model changes. To do that,

7
1. Go to the edx-platform dir
8
2. ./manage.py schemamigration courseware --auto description_of_your_change
9
3. Add the migration file created in edx-platform/lms/djangoapps/courseware/migrations/
10

11 12 13

ASSUMPTIONS: modules have unique IDs, even across different module_types

14
"""
15
import logging
16
import itertools
17

Piotr Mitros committed
18
from django.contrib.auth.models import User
19
from django.conf import settings
20 21
from django.db import models
from django.db.models.signals import post_save
22
from django.dispatch import receiver, Signal
Piotr Mitros committed
23

24
from model_utils.models import TimeStampedModel
25 26
from student.models import user_by_anonymous_id
from submissions.models import score_set, score_reset
27
import coursewarehistoryextended
28

29
from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField
30

31 32
log = logging.getLogger("edx.courseware")

33

34 35 36 37 38 39 40 41 42 43 44 45 46
def chunks(items, chunk_size):
    """
    Yields the values from items in chunks of size chunk_size
    """
    items = list(items)
    return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size))


class ChunkingManager(models.Manager):
    """
    :class:`~Manager` that adds an additional method :meth:`chunked_filter` to provide
    the ability to make select queries with specific chunk sizes.
    """
47 48 49
    class Meta(object):
        app_label = "courseware"

50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
    def chunked_filter(self, chunk_field, items, **kwargs):
        """
        Queries model_class with `chunk_field` set to chunks of size `chunk_size`,
        and all other parameters from `**kwargs`.

        This works around a limitation in sqlite3 on the number of parameters
        that can be put into a single query.

        Arguments:
            chunk_field (str): The name of the field to chunk the query on.
            items: The values for of chunk_field to select. This is chunked into ``chunk_size``
                chunks, and passed as the value for the ``chunk_field`` keyword argument to
                :meth:`~Manager.filter`. This implies that ``chunk_field`` should be an
                ``__in`` key.
            chunk_size (int): The size of chunks to pass. Defaults to 500.
        """
        chunk_size = kwargs.pop('chunk_size', 500)
        res = itertools.chain.from_iterable(
            self.filter(**dict([(chunk_field, chunk)] + kwargs.items()))
            for chunk in chunks(items, chunk_size)
        )
        return res


74
class StudentModule(models.Model):
75 76 77
    """
    Keeps student state for a particular module in a particular course.
    """
78
    objects = ChunkingManager()
79 80
    MODEL_TAGS = ['course_id', 'module_type']

Piotr Mitros committed
81 82
    # For a homework problem, contains a JSON
    # object consisting of state
83 84 85
    MODULE_TYPES = (('problem', 'problem'),
                    ('video', 'video'),
                    ('html', 'html'),
86 87 88
                    ('course', 'course'),
                    ('chapter', 'Section'),
                    ('sequential', 'Subsection'),
89
                    ('library_content', 'Library Content'))
90
    ## These three are the key for the object
91
    module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True)
92

93
    # Key used to share state. This is the XBlock usage_id
94
    module_state_key = LocationKeyField(max_length=255, db_index=True, db_column='module_id')
95
    student = models.ForeignKey(User, db_index=True)
96

97
    course_id = CourseKeyField(max_length=255, db_index=True)
98

99
    class Meta(object):
100
        app_label = "courseware"
101
        unique_together = (('student', 'module_state_key', 'course_id'),)
Ernie Park committed
102

utkjad committed
103
    # Internal state of the object
Piotr Mitros committed
104
    state = models.TextField(null=True, blank=True)
105

utkjad committed
106
    # Grade, and are we done?
107
    grade = models.FloatField(null=True, blank=True, db_index=True)
108
    max_grade = models.FloatField(null=True, blank=True)
109 110 111 112 113
    DONE_TYPES = (
        ('na', 'NOT_APPLICABLE'),
        ('f', 'FINISHED'),
        ('i', 'INCOMPLETE'),
    )
114 115
    done = models.CharField(max_length=8, choices=DONE_TYPES, default='na', db_index=True)

116 117
    created = models.DateTimeField(auto_now_add=True, db_index=True)
    modified = models.DateTimeField(auto_now=True, db_index=True)
118

119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
    @classmethod
    def all_submitted_problems_read_only(cls, course_id):
        """
        Return all model instances that correspond to problems that have been
        submitted for a given course. So module_type='problem' and a non-null
        grade. Use a read replica if one exists for this environment.
        """
        queryset = cls.objects.filter(
            course_id=course_id,
            module_type='problem',
            grade__isnull=False
        )
        if "read_replica" in settings.DATABASES:
            return queryset.using("read_replica")
        else:
            return queryset

136
    def __repr__(self):
137 138 139
        return 'StudentModule<%r>' % ({
            'course_id': self.course_id,
            'module_type': self.module_type,
140 141 142
            # We use the student_id instead of username to avoid a database hop.
            # This can actually matter in cases where we're logging many of
            # these (e.g. on a broken progress page).
143
            'student_id': self.student_id,
144 145 146
            'module_state_key': self.module_state_key,
            'state': str(self.state)[:20],
        },)
147

148 149
    def __unicode__(self):
        return unicode(repr(self))
150

151

152 153 154
class BaseStudentModuleHistory(models.Model):
    """Abstract class containing most fields used by any class
    storing Student Module History"""
155
    objects = ChunkingManager()
156 157
    HISTORY_SAVING_TYPES = {'problem'}

158
    class Meta(object):
159
        abstract = True
160 161 162 163 164 165 166 167 168

    version = models.CharField(max_length=255, null=True, blank=True, db_index=True)

    # This should be populated from the modified field in StudentModule
    created = models.DateTimeField(db_index=True)
    state = models.TextField(null=True, blank=True)
    grade = models.FloatField(null=True, blank=True)
    max_grade = models.FloatField(null=True, blank=True)

169 170
    @property
    def csm(self):
171
        """
172 173 174
        Finds the StudentModule object for this history record, even if our data is split
        across multiple data stores.  Django does not handle this correctly with the built-in
        student_module property.
175
        """
176
        return StudentModule.objects.get(pk=self.student_module_id)
177

178 179 180 181 182 183 184
    @staticmethod
    def get_history(student_modules):
        """
        Find history objects across multiple backend stores for a given StudentModule
        """

        history_entries = []
185

186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204
        if settings.FEATURES.get('ENABLE_CSMH_EXTENDED'):
            history_entries += coursewarehistoryextended.models.StudentModuleHistoryExtended.objects.filter(
                # Django will sometimes try to join to courseware_studentmodule
                # so just do an in query
                student_module__in=[module.id for module in student_modules]
            ).order_by('-id')

        # If we turn off reading from multiple history tables, then we don't want to read from
        # StudentModuleHistory anymore, we believe that all history is in the Extended table.
        if settings.FEATURES.get('ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES'):
            # we want to save later SQL queries on the model which allows us to prefetch
            history_entries += StudentModuleHistory.objects.prefetch_related('student_module').filter(
                student_module__in=student_modules
            ).order_by('-id')

        return history_entries


class StudentModuleHistory(BaseStudentModuleHistory):
205 206
    """Keeps a complete history of state changes for a given XModule for a given
    Student. Right now, we restrict this to problems so that the table doesn't
207
    explode in size."""
208 209 210 211 212

    class Meta(object):
        app_label = "courseware"
        get_latest_by = "created"

213
    student_module = models.ForeignKey(StudentModule, db_index=True)
214

215 216
    def __unicode__(self):
        return unicode(repr(self))
217 218 219 220

    def save_history(sender, instance, **kwargs):  # pylint: disable=no-self-argument, unused-argument
        """
        Checks the instance's module_type, and creates & saves a
221
        StudentModuleHistoryExtended entry if the module_type is one that
222 223
        we save.
        """
224 225
        if instance.module_type in StudentModuleHistory.HISTORY_SAVING_TYPES:
            history_entry = StudentModuleHistory(student_module=instance,
226 227 228
                                                 version=None,
                                                 created=instance.modified,
                                                 state=instance.state,
229 230 231 232
                                                 grade=instance.grade,
                                                 max_grade=instance.max_grade)
            history_entry.save()

233 234 235 236 237
    # When the extended studentmodulehistory table exists, don't save
    # duplicate history into courseware_studentmodulehistory, just retain
    # data for reading.
    if not settings.FEATURES.get('ENABLE_CSMH_EXTENDED'):
        post_save.connect(save_history, sender=StudentModule)
238

239

240
class XBlockFieldBase(models.Model):
241
    """
242
    Base class for all XBlock field storage.
243
    """
244 245
    objects = ChunkingManager()

246
    class Meta(object):
247
        app_label = "courseware"
248
        abstract = True
249 250 251 252

    # The name of the field
    field_name = models.CharField(max_length=64, db_index=True)

Calen Pennington committed
253
    # The value of the field. Defaults to None dumped as json
254 255 256 257 258 259
    value = models.TextField(default='null')

    created = models.DateTimeField(auto_now_add=True, db_index=True)
    modified = models.DateTimeField(auto_now=True, db_index=True)

    def __unicode__(self):
260 261 262
        return u'{}<{!r}'.format(
            self.__class__.__name__,
            {
263
                key: getattr(self, key)
264 265 266 267 268 269 270 271 272 273
                for key in self._meta.get_all_field_names()
                if key not in ('created', 'modified')
            }
        )


class XModuleUserStateSummaryField(XBlockFieldBase):
    """
    Stores data set in the Scope.user_state_summary scope by an xmodule field
    """
274
    class Meta(object):
275
        app_label = "courseware"
276 277 278 279
        unique_together = (('usage_id', 'field_name'),)

    # The definition id for the module
    usage_id = LocationKeyField(max_length=255, db_index=True)
280 281


282
class XModuleStudentPrefsField(XBlockFieldBase):
283
    """
284
    Stores data set in the Scope.preferences scope by an xmodule field
285
    """
286
    class Meta(object):
287
        app_label = "courseware"
288 289 290
        unique_together = (('student', 'module_type', 'field_name'),)

    # The type of the module for these preferences
291
    module_type = BlockTypeKeyField(max_length=64, db_index=True)
292 293 294 295

    student = models.ForeignKey(User, db_index=True)


296
class XModuleStudentInfoField(XBlockFieldBase):
297
    """
298
    Stores data set in the Scope.preferences scope by an xmodule field
299
    """
300
    class Meta(object):
301
        app_label = "courseware"
302
        unique_together = (('student', 'field_name'),)
303

304 305
    student = models.ForeignKey(User, db_index=True)

306 307 308 309 310 311

class OfflineComputedGrade(models.Model):
    """
    Table of grades computed offline for a given user and course.
    """
    user = models.ForeignKey(User, db_index=True)
312
    course_id = CourseKeyField(max_length=255, db_index=True)
313 314 315 316 317 318

    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
    updated = models.DateTimeField(auto_now=True, db_index=True)

    gradeset = models.TextField(null=True, blank=True)		# grades, stored as JSON

319
    class Meta(object):
320
        app_label = "courseware"
321 322 323 324 325 326 327 328 329 330 331
        unique_together = (('user', 'course_id'), )

    def __unicode__(self):
        return "[OfflineComputedGrade] %s: %s (%s) = %s" % (self.user, self.course_id, self.created, self.gradeset)


class OfflineComputedGradeLog(models.Model):
    """
    Log of when offline grades are computed.
    Use this to be able to show instructor when the last computed grades were done.
    """
332
    class Meta(object):
333
        app_label = "courseware"
334 335 336
        ordering = ["-created"]
        get_latest_by = "created"

337
    course_id = CourseKeyField(max_length=255, db_index=True)
338
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
Calen Pennington committed
339
    seconds = models.IntegerField(default=0)  	# seconds elapsed for computation
340 341 342
    nstudents = models.IntegerField(default=0)

    def __unicode__(self):
343
        return "[OCGLog] %s: %s" % (self.course_id.to_deprecated_string(), self.created)  # pylint: disable=no-member
344 345


346
class StudentFieldOverride(TimeStampedModel):
347 348 349 350 351 352 353 354 355
    """
    Holds the value of a specific field overriden for a student.  This is used
    by the code in the `courseware.student_field_overrides` module to provide
    overrides of xblock fields on a per user basis.
    """
    course_id = CourseKeyField(max_length=255, db_index=True)
    location = LocationKeyField(max_length=255, db_index=True)
    student = models.ForeignKey(User, db_index=True)

356
    class Meta(object):
357
        app_label = "courseware"
358
        unique_together = (('course_id', 'field', 'location', 'student'),)
359 360 361

    field = models.CharField(max_length=255)
    value = models.TextField(default='null')
362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 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 452 453 454 455 456 457 458 459


# Signal that indicates that a user's score for a problem has been updated.
# This signal is generated when a scoring event occurs either within the core
# platform or in the Submissions module. Note that this signal will be triggered
# regardless of the new and previous values of the score (i.e. it may be the
# case that this signal is generated when a user re-attempts a problem but
# receives the same score).
SCORE_CHANGED = Signal(
    providing_args=[
        'points_possible',  # Maximum score available for the exercise
        'points_earned',   # Score obtained by the user
        'user_id',  # Integer User ID
        'course_id',  # Unicode string representing the course
        'usage_id'  # Unicode string indicating the courseware instance
    ]
)


@receiver(score_set)
def submissions_score_set_handler(sender, **kwargs):  # pylint: disable=unused-argument
    """
    Consume the score_set signal defined in the Submissions API, and convert it
    to a SCORE_CHANGED signal defined in this module. Converts the unicode keys
    for user, course and item into the standard representation for the
    SCORE_CHANGED signal.

    This method expects that the kwargs dictionary will contain the following
    entries (See the definition of score_set):
      - 'points_possible': integer,
      - 'points_earned': integer,
      - 'anonymous_user_id': unicode,
      - 'course_id': unicode,
      - 'item_id': unicode
    """
    points_possible = kwargs.get('points_possible', None)
    points_earned = kwargs.get('points_earned', None)
    course_id = kwargs.get('course_id', None)
    usage_id = kwargs.get('item_id', None)
    user = None
    if 'anonymous_user_id' in kwargs:
        user = user_by_anonymous_id(kwargs.get('anonymous_user_id'))

    # If any of the kwargs were missing, at least one of the following values
    # will be None.
    if all((user, points_possible, points_earned, course_id, usage_id)):
        SCORE_CHANGED.send(
            sender=None,
            points_possible=points_possible,
            points_earned=points_earned,
            user_id=user.id,
            course_id=course_id,
            usage_id=usage_id
        )
    else:
        log.exception(
            u"Failed to process score_set signal from Submissions API. "
            "points_possible: %s, points_earned: %s, user: %s, course_id: %s, "
            "usage_id: %s", points_possible, points_earned, user, course_id, usage_id
        )


@receiver(score_reset)
def submissions_score_reset_handler(sender, **kwargs):  # pylint: disable=unused-argument
    """
    Consume the score_reset signal defined in the Submissions API, and convert
    it to a SCORE_CHANGED signal indicating that the score has been set to 0/0.
    Converts the unicode keys for user, course and item into the standard
    representation for the SCORE_CHANGED signal.

    This method expects that the kwargs dictionary will contain the following
    entries (See the definition of score_reset):
      - 'anonymous_user_id': unicode,
      - 'course_id': unicode,
      - 'item_id': unicode
    """
    course_id = kwargs.get('course_id', None)
    usage_id = kwargs.get('item_id', None)
    user = None
    if 'anonymous_user_id' in kwargs:
        user = user_by_anonymous_id(kwargs.get('anonymous_user_id'))

    # If any of the kwargs were missing, at least one of the following values
    # will be None.
    if all((user, course_id, usage_id)):
        SCORE_CHANGED.send(
            sender=None,
            points_possible=0,
            points_earned=0,
            user_id=user.id,
            course_id=course_id,
            usage_id=usage_id
        )
    else:
        log.exception(
            u"Failed to process score_reset signal from Submissions API. "
            "user: %s, course_id: %s, usage_id: %s", user, course_id, usage_id
        )