models.py 14.2 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 itertools
16
import logging
17

18
from config_models.models import ConfigurationModel
19
from django.conf import settings
20
from django.contrib.auth.models import User
21 22
from django.db import models
from django.db.models.signals import post_save
23
from django.utils.translation import ugettext_lazy as _
24 25
from model_utils.models import TimeStampedModel

26 27
import coursewarehistoryextended
from openedx.core.djangoapps.xmodule_django.models import BlockTypeKeyField, CourseKeyField, LocationKeyField
28

29 30
log = logging.getLogger("edx.courseware")

31

32 33 34 35 36 37 38 39 40 41 42 43 44
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.
    """
45

46 47 48
    class Meta(object):
        app_label = "courseware"

49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
    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


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

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

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

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

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

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

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

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

118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
    @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

135
    def __repr__(self):
136 137 138 139 140 141 142 143 144 145 146
        return 'StudentModule<%r>' % (
            {
                'course_id': self.course_id,
                'module_type': self.module_type,
                # 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).
                'student_id': self.student_id,
                '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

275
    class Meta(object):
276
        app_label = "courseware"
277 278 279 280
        unique_together = (('usage_id', 'field_name'),)

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


283
class XModuleStudentPrefsField(XBlockFieldBase):
284
    """
285
    Stores data set in the Scope.preferences scope by an xmodule field
286
    """
287

288
    class Meta(object):
289
        app_label = "courseware"
290 291 292
        unique_together = (('student', 'module_type', 'field_name'),)

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

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


298
class XModuleStudentInfoField(XBlockFieldBase):
299
    """
300
    Stores data set in the Scope.preferences scope by an xmodule field
301
    """
302

303
    class Meta(object):
304
        app_label = "courseware"
305
        unique_together = (('student', 'field_name'),)
306

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

309 310 311 312 313 314

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

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

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

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

    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.
    """
335

336
    class Meta(object):
337
        app_label = "courseware"
338 339 340
        ordering = ["-created"]
        get_latest_by = "created"

341
    course_id = CourseKeyField(max_length=255, db_index=True)
342
    created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
343
    seconds = models.IntegerField(default=0)  # seconds elapsed for computation
344 345 346
    nstudents = models.IntegerField(default=0)

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


350
class StudentFieldOverride(TimeStampedModel):
351 352 353 354 355 356 357 358 359
    """
    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)

360
    class Meta(object):
361
        app_label = "courseware"
362
        unique_together = (('course_id', 'field', 'location', 'student'),)
363 364 365

    field = models.CharField(max_length=255)
    value = models.TextField(default='null')
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


class DynamicUpgradeDeadlineConfiguration(ConfigurationModel):
    """ Dynamic upgrade deadline configuration.

    This model controls the behavior of the dynamic upgrade deadline for self-paced courses.
    """
    class Meta(object):
        app_label = 'courseware'

    deadline_days = models.PositiveSmallIntegerField(
        default=21,
        help_text=_('Number of days a learner has to upgrade after content is made available')
    )


class CourseDynamicUpgradeDeadlineConfiguration(ConfigurationModel):
    """
    Per-course run configuration for dynamic upgrade deadlines.

    This model controls dynamic upgrade deadlines on a per-course run level, allowing course runs to
    have different deadlines or opt out of the functionality altogether.
    """
    class Meta(object):
        app_label = 'courseware'

    KEY_FIELDS = ('course_id',)

    course_id = CourseKeyField(max_length=255, db_index=True)
    deadline_days = models.PositiveSmallIntegerField(
        default=21,
        help_text=_('Number of days a learner has to upgrade after content is made available')
    )
    opt_out = models.BooleanField(
        default=False,
        help_text=_('Disable the dynamic upgrade deadline for this course run.')
    )