model_data.py 15.7 KB
Newer Older
1 2 3 4
"""
Classes to provide the LMS runtime data storage to XBlocks
"""

5
import json
Calen Pennington committed
6
from collections import defaultdict
7
from itertools import chain
8 9
from .models import (
    StudentModule,
Calen Pennington committed
10
    XModuleUserStateSummaryField,
11 12 13
    XModuleStudentPrefsField,
    XModuleStudentInfoField
)
14
import logging
15 16 17
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.block_types import BlockTypeKeyV1
from opaque_keys.edx.asides import AsideUsageKeyV1
18 19

from django.db import DatabaseError
20

Calen Pennington committed
21 22
from xblock.runtime import KeyValueStore
from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError
23
from xblock.fields import Scope, UserScope
24
from xmodule.modulestore.django import modulestore
25
from xblock.core import XBlockAside
26 27

log = logging.getLogger(__name__)
28 29 30


class InvalidWriteError(Exception):
31 32 33 34
    """
    Raised to indicate that writing to a particular key
    in the KeyValueStore is disabled
    """
35 36


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


Calen Pennington committed
45
class FieldDataCache(object):
46 47
    """
    A cache of django model objects needed to supply the data
48
    for a module and its decendants
49
    """
50
    def __init__(self, descriptors, course_id, user, select_for_update=False, asides=None):
51 52 53 54 55 56 57
        '''
        Find any courseware.models objects that are needed by any descriptor
        in descriptors. Attempts to minimize the number of queries to the database.
        Note: Only modules that have store_state = True or have shared
        state will have a StudentModule.

        Arguments
58
        descriptors: A list of XModuleDescriptors.
59 60
        course_id: The id of the current course
        user: The user for which to cache data
61
        select_for_update: True if rows should be locked until end of transaction
62
        asides: The list of aside types to load, or None to prefetch no asides.
63 64 65 66
        '''
        self.cache = {}
        self.descriptors = descriptors
        self.select_for_update = select_for_update
67

68 69 70 71 72
        if asides is None:
            self.asides = []
        else:
            self.asides = asides

73
        assert isinstance(course_id, CourseKey)
74 75 76 77 78 79 80 81 82 83 84
        self.course_id = course_id
        self.user = user

        if user.is_authenticated():
            for scope, fields in self._fields_to_cache().items():
                for field_object in self._retrieve_fields(scope, fields):
                    self.cache[self._cache_key_from_field_object(scope, field_object)] = field_object

    @classmethod
    def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None,
                                         descriptor_filter=lambda descriptor: True,
85
                                         select_for_update=False, asides=None):
86 87 88 89 90 91 92 93 94 95 96 97
        """
        course_id: the course in the context of which we want StudentModules.
        user: the django user for whom to load modules.
        descriptor: An XModuleDescriptor
        depth is the number of levels of descendent modules to load StudentModules for, in addition to
            the supplied descriptor. If depth is None, load all descendent StudentModules
        descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
            should be cached
        select_for_update: Flag indicating whether the rows should be locked until end of transaction
        """

        def get_child_descriptors(descriptor, depth, descriptor_filter):
98 99 100 101 102 103 104 105 106
            """
            Return a list of all child descriptors down to the specified depth
            that match the descriptor filter. Includes `descriptor`

            descriptor: The parent to search inside
            depth: The number of levels to descend, or None for infinite depth
            descriptor_filter(descriptor): A function that returns True
                if descriptor should be included in the results
            """
107 108 109 110 111 112 113 114
            if descriptor_filter(descriptor):
                descriptors = [descriptor]
            else:
                descriptors = []

            if depth is None or depth > 0:
                new_depth = depth - 1 if depth is not None else depth

115
                for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
116 117 118 119
                    descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))

            return descriptors

120
        with modulestore().bulk_operations(descriptor.location.course_key):
121
            descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
122

123
        return FieldDataCache(descriptors, course_id, user, select_for_update, asides=asides)
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149

    def _query(self, model_class, **kwargs):
        """
        Queries model_class with **kwargs, optionally adding select_for_update if
        self.select_for_update is set
        """
        query = model_class.objects
        if self.select_for_update:
            query = query.select_for_update()
        query = query.filter(**kwargs)
        return query

    def _chunked_query(self, model_class, chunk_field, items, chunk_size=500, **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
        """
        res = chain.from_iterable(
            self._query(model_class, **dict([(chunk_field, chunk)] + kwargs.items()))
            for chunk in chunks(items, chunk_size)
        )
        return res

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 175 176 177 178
    @property
    def _all_usage_ids(self):
        """
        Return a set of all usage_ids for the descriptors that this FieldDataCache is caching
        against, and well as all asides for those descriptors.
        """
        usage_ids = set()
        for descriptor in self.descriptors:
            usage_ids.add(descriptor.scope_ids.usage_id)

            for aside_type in self.asides:
                usage_ids.add(AsideUsageKeyV1(descriptor.scope_ids.usage_id, aside_type))

        return usage_ids

    @property
    def _all_block_types(self):
        """
        Return a set of all block_types that are cached by this FieldDataCache.
        """
        block_types = set()
        for descriptor in self.descriptors:
            block_types.add(BlockTypeKeyV1(descriptor.entry_point, descriptor.scope_ids.block_type))

        for aside_type in self.asides:
            block_types.add(BlockTypeKeyV1(XBlockAside.entry_point, aside_type))

        return block_types

179 180 181 182
    def _retrieve_fields(self, scope, fields):
        """
        Queries the database for all of the fields in the specified scope
        """
Calen Pennington committed
183
        if scope == Scope.user_state:
184 185
            return self._chunked_query(
                StudentModule,
186
                'module_state_key__in',
187
                self._all_usage_ids,
188
                course_id=self.course_id,
189
                student=self.user.pk,
190
            )
Calen Pennington committed
191
        elif scope == Scope.user_state_summary:
192
            return self._chunked_query(
Calen Pennington committed
193
                XModuleUserStateSummaryField,
194
                'usage_id__in',
195
                self._all_usage_ids,
196 197
                field_name__in=set(field.name for field in fields),
            )
198
        elif scope == Scope.preferences:
199 200 201
            return self._chunked_query(
                XModuleStudentPrefsField,
                'module_type__in',
202
                self._all_block_types,
203
                student=self.user.pk,
204 205
                field_name__in=set(field.name for field in fields),
            )
206
        elif scope == Scope.user_info:
207
            return self._query(
208
                XModuleStudentInfoField,
209
                student=self.user.pk,
210 211 212
                field_name__in=set(field.name for field in fields),
            )
        else:
Calen Pennington committed
213
            return []
214 215 216 217 218 219 220

    def _fields_to_cache(self):
        """
        Returns a map of scopes to fields in that scope that should be cached
        """
        scope_map = defaultdict(set)
        for descriptor in self.descriptors:
Calen Pennington committed
221
            for field in descriptor.fields.values():
222 223 224 225
                scope_map[field.scope].add(field)
        return scope_map

    def _cache_key_from_kvs_key(self, key):
226
        """
Calen Pennington committed
227
        Return the key used in the FieldDataCache for the specified KeyValueStore key
228
        """
229
        if key.scope == Scope.user_state:
230
            return (key.scope, key.block_scope_id)
Calen Pennington committed
231
        elif key.scope == Scope.user_state_summary:
232
            return (key.scope, key.block_scope_id, key.field_name)
233
        elif key.scope == Scope.preferences:
234
            return (key.scope, BlockTypeKeyV1(key.block_family, key.block_scope_id), key.field_name)
235
        elif key.scope == Scope.user_info:
236 237 238
            return (key.scope, key.field_name)

    def _cache_key_from_field_object(self, scope, field_object):
239
        """
Calen Pennington committed
240
        Return the key used in the FieldDataCache for the specified scope and
241 242
        field
        """
243
        if scope == Scope.user_state:
244
            return (scope, field_object.module_state_key.map_into_course(self.course_id))
Calen Pennington committed
245
        elif scope == Scope.user_state_summary:
246
            return (scope, field_object.usage_id.map_into_course(self.course_id), field_object.field_name)
247
        elif scope == Scope.preferences:
248
            return (scope, field_object.module_type, field_object.field_name)
249
        elif scope == Scope.user_info:
250 251 252 253
            return (scope, field_object.field_name)

    def find(self, key):
        '''
Calen Pennington committed
254
        Look for a model data object using an DjangoKeyValueStore.Key object
255

Calen Pennington committed
256
        key: An `DjangoKeyValueStore.Key` object selecting the object to find
257 258 259

        returns the found object, or None if the object doesn't exist
        '''
260 261 262 263 264
        if key.scope.user == UserScope.ONE and not self.user.is_anonymous():
            # If we're getting user data, we expect that the key matches the
            # user we were constructed for.
            assert key.user_id == self.user.id

265 266 267 268 269 270 271 272 273 274 275 276
        return self.cache.get(self._cache_key_from_kvs_key(key))

    def find_or_create(self, key):
        '''
        Find a model data object in this cache, or create it if it doesn't
        exist
        '''
        field_object = self.find(key)

        if field_object is not None:
            return field_object

277
        if key.scope == Scope.user_state:
278
            field_object, __ = StudentModule.objects.get_or_create(
279
                course_id=self.course_id,
280
                student_id=key.user_id,
281
                module_state_key=key.block_scope_id,
282 283
                defaults={
                    'state': json.dumps({}),
284
                    'module_type': key.block_scope_id.block_type,
285
                },
286
            )
Calen Pennington committed
287
        elif key.scope == Scope.user_state_summary:
288
            field_object, __ = XModuleUserStateSummaryField.objects.get_or_create(
289
                field_name=key.field_name,
290
                usage_id=key.block_scope_id
291
            )
292
        elif key.scope == Scope.preferences:
293
            field_object, __ = XModuleStudentPrefsField.objects.get_or_create(
294
                field_name=key.field_name,
295
                module_type=BlockTypeKeyV1(key.block_family, key.block_scope_id),
296
                student_id=key.user_id,
297
            )
298
        elif key.scope == Scope.user_info:
299
            field_object, __ = XModuleStudentInfoField.objects.get_or_create(
300
                field_name=key.field_name,
301
                student_id=key.user_id,
302 303 304 305 306 307 308
            )

        cache_key = self._cache_key_from_kvs_key(key)
        self.cache[cache_key] = field_object
        return field_object


Calen Pennington committed
309
class DjangoKeyValueStore(KeyValueStore):
310
    """
Calen Pennington committed
311 312
    This KeyValueStore will read and write data in the following scopes to django models
        Scope.user_state_summary
313 314 315
        Scope.user_state
        Scope.preferences
        Scope.user_info
Calen Pennington committed
316 317

    Access to any other scopes will raise an InvalidScopeError
318

319
    Data for Scope.user_state is stored as StudentModule objects via the django orm.
320 321 322 323 324 325

    Data for the other scopes is stored in individual objects that are named for the
    scope involved and have the field name as a key

    If the key isn't found in the expected table during a read or a delete, then a KeyError will be raised
    """
Calen Pennington committed
326 327

    _allowed_scopes = (
Calen Pennington committed
328
        Scope.user_state_summary,
329 330 331
        Scope.user_state,
        Scope.preferences,
        Scope.user_info,
Calen Pennington committed
332
    )
333

Calen Pennington committed
334 335
    def __init__(self, field_data_cache):
        self._field_data_cache = field_data_cache
336

Calen Pennington committed
337
    def get(self, key):
Calen Pennington committed
338
        if key.scope not in self._allowed_scopes:
339
            raise InvalidScopeError(key)
Calen Pennington committed
340

Calen Pennington committed
341
        field_object = self._field_data_cache.find(key)
342
        if field_object is None:
343 344
            raise KeyError(key.field_name)

345
        if key.scope == Scope.user_state:
346 347 348 349
            return json.loads(field_object.state)[key.field_name]
        else:
            return json.loads(field_object.value)

350
    def set(self, key, value):
351 352 353 354
        """
        Set a single value in the KeyValueStore
        """
        self.set_many({key: value})
355

356 357 358 359 360
    def set_many(self, kv_dict):
        """
        Provide a bulk save mechanism.

        `kv_dict`: A dictionary of dirty fields that maps
361
          xblock.KvsFieldData._key : value
362 363 364 365 366 367

        """
        saved_fields = []
        # field_objects maps a field_object to a list of associated fields
        field_objects = dict()
        for field in kv_dict:
368
            # Check field for validity
369
            if field.scope not in self._allowed_scopes:
370
                raise InvalidScopeError(field)
371

372
            # If the field is valid and isn't already in the dictionary, add it.
Calen Pennington committed
373
            field_object = self._field_data_cache.find_or_create(field)
374 375
            if field_object not in field_objects.keys():
                field_objects[field_object] = []
376
            # Update the list of associated fields
377 378
            field_objects[field_object].append(field)

379
            # Special case when scope is for the user state, because this scope saves fields in a single row
380 381 382 383 384
            if field.scope == Scope.user_state:
                state = json.loads(field_object.state)
                state[field.field_name] = kv_dict[field]
                field_object.state = json.dumps(state)
            else:
David Baumgold committed
385 386
                # The remaining scopes save fields on different rows, so
                # we don't have to worry about conflicts
387 388 389 390 391 392 393 394 395 396
                field_object.value = json.dumps(kv_dict[field])

        for field_object in field_objects:
            try:
                # Save the field object that we made above
                field_object.save()
                # If save is successful on this scope, add the saved fields to
                # the list of successful saves
                saved_fields.extend([field.field_name for field in field_objects[field_object]])
            except DatabaseError:
397
                log.exception('Error saving fields %r', field_objects[field_object])
398 399
                raise KeyValueMultiSaveError(saved_fields)

400
    def delete(self, key):
Calen Pennington committed
401
        if key.scope not in self._allowed_scopes:
402
            raise InvalidScopeError(key)
Calen Pennington committed
403

Calen Pennington committed
404
        field_object = self._field_data_cache.find(key)
405
        if field_object is None:
406 407
            raise KeyError(key.field_name)

408
        if key.scope == Scope.user_state:
409 410 411 412 413 414
            state = json.loads(field_object.state)
            del state[key.field_name]
            field_object.state = json.dumps(state)
            field_object.save()
        else:
            field_object.delete()
415

416 417
    def has(self, key):
        if key.scope not in self._allowed_scopes:
418
            raise InvalidScopeError(key)
419

Calen Pennington committed
420
        field_object = self._field_data_cache.find(key)
421 422 423
        if field_object is None:
            return False

424
        if key.scope == Scope.user_state:
425 426 427
            return key.field_name in json.loads(field_object.state)
        else:
            return True