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

5
import json
6 7
from collections import namedtuple, defaultdict
from itertools import chain
8 9 10 11 12 13 14 15
from .models import (
    StudentModule,
    XModuleContentField,
    XModuleSettingsField,
    XModuleStudentPrefsField,
    XModuleStudentInfoField
)

16 17
from xblock.runtime import KeyValueStore, InvalidScopeError
from xblock.core import Scope
18 19 20


class InvalidWriteError(Exception):
21 22 23 24
    """
    Raised to indicate that writing to a particular key
    in the KeyValueStore is disabled
    """
25 26


27
def chunks(items, chunk_size):
28 29 30
    """
    Yields the values from items in chunks of size chunk_size
    """
31 32 33 34 35 36 37
    items = list(items)
    return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size))


class ModelDataCache(object):
    """
    A cache of django model objects needed to supply the data
38
    for a module and its decendants
39 40 41 42 43 44 45 46 47
    """
    def __init__(self, descriptors, course_id, user, select_for_update=False):
        '''
        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
48
        descriptors: A list of XModuleDescriptors.
49 50
        course_id: The id of the current course
        user: The user for which to cache data
51
        select_for_update: True if rows should be locked until end of transaction
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
        '''
        self.cache = {}
        self.descriptors = descriptors
        self.select_for_update = select_for_update
        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,
                                         select_for_update=False):
        """
        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):
80 81 82 83 84 85 86 87 88
            """
            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
            """
89 90 91 92 93 94 95 96
            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

97
                for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
                    descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))

            return descriptors

        descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)

        return ModelDataCache(descriptors, course_id, user, select_for_update)

    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

    def _retrieve_fields(self, scope, fields):
        """
        Queries the database for all of the fields in the specified scope
        """
        if scope in (Scope.children, Scope.parent):
            return []
137
        elif scope == Scope.user_state:
138 139 140 141 142
            return self._chunked_query(
                StudentModule,
                'module_state_key__in',
                (descriptor.location.url() for descriptor in self.descriptors),
                course_id=self.course_id,
143
                student=self.user.pk,
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
            )
        elif scope == Scope.content:
            return self._chunked_query(
                XModuleContentField,
                'definition_id__in',
                (descriptor.location.url() for descriptor in self.descriptors),
                field_name__in=set(field.name for field in fields),
            )
        elif scope == Scope.settings:
            return self._chunked_query(
                XModuleSettingsField,
                'usage_id__in',
                (
                    '%s-%s' % (self.course_id, descriptor.location.url())
                    for descriptor in self.descriptors
                ),
                field_name__in=set(field.name for field in fields),
            )
162
        elif scope == Scope.preferences:
163 164 165 166
            return self._chunked_query(
                XModuleStudentPrefsField,
                'module_type__in',
                set(descriptor.location.category for descriptor in self.descriptors),
167
                student=self.user.pk,
168 169
                field_name__in=set(field.name for field in fields),
            )
170
        elif scope == Scope.user_info:
171
            return self._query(
172
                XModuleStudentInfoField,
173
                student=self.user.pk,
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
                field_name__in=set(field.name for field in fields),
            )
        else:
            raise InvalidScopeError(scope)

    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:
            for field in (descriptor.module_class.fields + descriptor.module_class.lms.fields):
                scope_map[field.scope].add(field)
        return scope_map

    def _cache_key_from_kvs_key(self, key):
190 191 192
        """
        Return the key used in the ModelDataCache for the specified KeyValueStore key
        """
193
        if key.scope == Scope.user_state:
194 195 196 197 198
            return (key.scope, key.block_scope_id.url())
        elif key.scope == Scope.content:
            return (key.scope, key.block_scope_id.url(), key.field_name)
        elif key.scope == Scope.settings:
            return (key.scope, '%s-%s' % (self.course_id, key.block_scope_id.url()), key.field_name)
199
        elif key.scope == Scope.preferences:
200
            return (key.scope, key.block_scope_id, key.field_name)
201
        elif key.scope == Scope.user_info:
202 203 204
            return (key.scope, key.field_name)

    def _cache_key_from_field_object(self, scope, field_object):
205 206 207 208
        """
        Return the key used in the ModelDataCache for the specified scope and
        field
        """
209
        if scope == Scope.user_state:
210 211 212 213 214
            return (scope, field_object.module_state_key)
        elif scope == Scope.content:
            return (scope, field_object.definition_id, field_object.field_name)
        elif scope == Scope.settings:
            return (scope, field_object.usage_id, field_object.field_name)
215
        elif scope == Scope.preferences:
216
            return (scope, field_object.module_type, field_object.field_name)
217
        elif scope == Scope.user_info:
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
            return (scope, field_object.field_name)

    def find(self, key):
        '''
        Look for a model data object using an LmsKeyValueStore.Key object

        key: An `LmsKeyValueStore.Key` object selecting the object to find

        returns the found object, or None if the object doesn't exist
        '''
        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

240
        if key.scope == Scope.user_state:
241 242
            field_object, _ = StudentModule.objects.get_or_create(
                course_id=self.course_id,
243
                student=self.user,
244
                module_state_key=key.block_scope_id.url(),
245 246 247
                defaults={'state': json.dumps({}),
                          'module_type': key.block_scope_id.category,
                         },
248 249 250 251 252 253 254 255 256 257 258
            )
        elif key.scope == Scope.content:
            field_object, _ = XModuleContentField.objects.get_or_create(
                field_name=key.field_name,
                definition_id=key.block_scope_id.url()
            )
        elif key.scope == Scope.settings:
            field_object, _ = XModuleSettingsField.objects.get_or_create(
                field_name=key.field_name,
                usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()),
            )
259
        elif key.scope == Scope.preferences:
260
            field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
261 262
                field_name=key.field_name,
                module_type=key.block_scope_id,
263
                student=self.user,
264
            )
265
        elif key.scope == Scope.user_info:
266 267
            field_object, _ = XModuleStudentInfoField.objects.get_or_create(
                field_name=key.field_name,
268
                student=self.user,
269 270 271 272 273 274 275
            )

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


276 277 278 279 280 281 282 283 284
class LmsKeyValueStore(KeyValueStore):
    """
    This KeyValueStore will read data from descriptor_model_data if it exists,
    but will not overwrite any keys set in descriptor_model_data. Attempts to do so will
    raise an InvalidWriteError.

    If the scope to write to is not one of the 5 named scopes:
        Scope.content
        Scope.settings
285 286 287
        Scope.user_state
        Scope.preferences
        Scope.user_info
288 289
    then an InvalidScopeError will be raised.

290
    Data for Scope.user_state is stored as StudentModule objects via the django orm.
291 292 293 294 295 296

    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
297 298 299 300

    _allowed_scopes = (
        Scope.content,
        Scope.settings,
301 302 303
        Scope.user_state,
        Scope.preferences,
        Scope.user_info,
Calen Pennington committed
304 305
        Scope.children,
    )
306

307
    def __init__(self, descriptor_model_data, model_data_cache):
308
        self._descriptor_model_data = descriptor_model_data
309
        self._model_data_cache = model_data_cache
310 311 312 313 314

    def get(self, key):
        if key.field_name in self._descriptor_model_data:
            return self._descriptor_model_data[key.field_name]

315 316 317
        if key.scope == Scope.parent:
            return None

Calen Pennington committed
318 319 320
        if key.scope not in self._allowed_scopes:
            raise InvalidScopeError(key.scope)

321 322
        field_object = self._model_data_cache.find(key)
        if field_object is None:
323 324
            raise KeyError(key.field_name)

325
        if key.scope == Scope.user_state:
326 327 328 329
            return json.loads(field_object.state)[key.field_name]
        else:
            return json.loads(field_object.value)

330 331 332 333
    def set(self, key, value):
        if key.field_name in self._descriptor_model_data:
            raise InvalidWriteError("Not allowed to overwrite descriptor model data", key.field_name)

334 335
        field_object = self._model_data_cache.find_or_create(key)

Calen Pennington committed
336 337 338
        if key.scope not in self._allowed_scopes:
            raise InvalidScopeError(key.scope)

339
        if key.scope == Scope.user_state:
340
            state = json.loads(field_object.state)
341
            state[key.field_name] = value
342 343 344 345 346
            field_object.state = json.dumps(state)
        else:
            field_object.value = json.dumps(value)

        field_object.save()
347 348 349 350 351

    def delete(self, key):
        if key.field_name in self._descriptor_model_data:
            raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name)

Calen Pennington committed
352 353 354
        if key.scope not in self._allowed_scopes:
            raise InvalidScopeError(key.scope)

355 356
        field_object = self._model_data_cache.find(key)
        if field_object is None:
357 358
            raise KeyError(key.field_name)

359
        if key.scope == Scope.user_state:
360 361 362 363 364 365
            state = json.loads(field_object.state)
            del state[key.field_name]
            field_object.state = json.dumps(state)
            field_object.save()
        else:
            field_object.delete()
366

367 368 369 370 371 372 373 374 375 376 377 378 379 380
    def has(self, key):
        if key.field_name in self._descriptor_model_data:
            return key.field_name in self._descriptor_model_data

        if key.scope == Scope.parent:
            return True

        if key.scope not in self._allowed_scopes:
            raise InvalidScopeError(key.scope)

        field_object = self._model_data_cache.find(key)
        if field_object is None:
            return False

381
        if key.scope == Scope.user_state:
382 383 384 385
            return key.field_name in json.loads(field_object.state)
        else:
            return True

386 387

LmsUsage = namedtuple('LmsUsage', 'id, def_id')