model_data.py 14.3 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 15 16
import logging

from django.db import DatabaseError
17
from django.contrib.auth.models import User
18

Calen Pennington committed
19 20
from xblock.runtime import KeyValueStore
from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError
21
from xblock.fields import Scope, UserScope
22 23

log = logging.getLogger(__name__)
24 25 26


class InvalidWriteError(Exception):
27 28 29 30
    """
    Raised to indicate that writing to a particular key
    in the KeyValueStore is disabled
    """
31 32


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


Calen Pennington committed
41
class FieldDataCache(object):
42 43
    """
    A cache of django model objects needed to supply the data
44
    for a module and its decendants
45 46 47 48 49 50 51 52 53
    """
    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
54
        descriptors: A list of XModuleDescriptors.
55 56
        course_id: The id of the current course
        user: The user for which to cache data
57
        select_for_update: True if rows should be locked until end of transaction
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
        '''
        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):
86 87 88 89 90 91 92 93 94
            """
            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
            """
95 96 97 98 99 100 101 102
            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

103
                for child in descriptor.get_children() + descriptor.get_required_module_descriptors():
104 105 106 107 108 109
                    descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))

            return descriptors

        descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)

Calen Pennington committed
110
        return FieldDataCache(descriptors, course_id, user, select_for_update)
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 137 138 139 140

    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
        """
Calen Pennington committed
141
        if scope == Scope.user_state:
142 143 144
            return self._chunked_query(
                StudentModule,
                'module_state_key__in',
145
                (str(descriptor.scope_ids.usage_id) for descriptor in self.descriptors),
146
                course_id=self.course_id,
147
                student=self.user.pk,
148
            )
Calen Pennington committed
149
        elif scope == Scope.user_state_summary:
150
            return self._chunked_query(
Calen Pennington committed
151
                XModuleUserStateSummaryField,
152
                'usage_id__in',
153
                (str(descriptor.scope_ids.usage_id) for descriptor in self.descriptors),
154 155
                field_name__in=set(field.name for field in fields),
            )
156
        elif scope == Scope.preferences:
157 158 159
            return self._chunked_query(
                XModuleStudentPrefsField,
                'module_type__in',
160
                set(descriptor.scope_ids.block_type for descriptor in self.descriptors),
161
                student=self.user.pk,
162 163
                field_name__in=set(field.name for field in fields),
            )
164
        elif scope == Scope.user_info:
165
            return self._query(
166
                XModuleStudentInfoField,
167
                student=self.user.pk,
168 169 170
                field_name__in=set(field.name for field in fields),
            )
        else:
Calen Pennington committed
171
            return []
172 173 174 175 176 177 178

    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
179
            for field in descriptor.fields.values():
180 181 182 183
                scope_map[field.scope].add(field)
        return scope_map

    def _cache_key_from_kvs_key(self, key):
184
        """
Calen Pennington committed
185
        Return the key used in the FieldDataCache for the specified KeyValueStore key
186
        """
187
        if key.scope == Scope.user_state:
188
            return (key.scope, key.block_scope_id.url())
Calen Pennington committed
189
        elif key.scope == Scope.user_state_summary:
190
            return (key.scope, key.block_scope_id.url(), key.field_name)
191
        elif key.scope == Scope.preferences:
192
            return (key.scope, key.block_scope_id, key.field_name)
193
        elif key.scope == Scope.user_info:
194 195 196
            return (key.scope, key.field_name)

    def _cache_key_from_field_object(self, scope, field_object):
197
        """
Calen Pennington committed
198
        Return the key used in the FieldDataCache for the specified scope and
199 200
        field
        """
201
        if scope == Scope.user_state:
202
            return (scope, field_object.module_state_key)
Calen Pennington committed
203
        elif scope == Scope.user_state_summary:
204
            return (scope, field_object.usage_id, field_object.field_name)
205
        elif scope == Scope.preferences:
206
            return (scope, field_object.module_type, field_object.field_name)
207
        elif scope == Scope.user_info:
208 209 210 211
            return (scope, field_object.field_name)

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

Calen Pennington committed
214
        key: An `DjangoKeyValueStore.Key` object selecting the object to find
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229

        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

230 231 232 233 234
        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

235
        if key.scope == Scope.user_state:
236 237
            field_object, _ = StudentModule.objects.get_or_create(
                course_id=self.course_id,
238
                student=User.objects.get(id=key.user_id),
239
                module_state_key=key.block_scope_id.url(),
240 241 242 243
                defaults={
                    'state': json.dumps({}),
                    'module_type': key.block_scope_id.category,
                },
244
            )
Calen Pennington committed
245 246
        elif key.scope == Scope.user_state_summary:
            field_object, _ = XModuleUserStateSummaryField.objects.get_or_create(
247
                field_name=key.field_name,
Calen Pennington committed
248
                usage_id=key.block_scope_id.url()
249
            )
250
        elif key.scope == Scope.preferences:
251
            field_object, _ = XModuleStudentPrefsField.objects.get_or_create(
252 253
                field_name=key.field_name,
                module_type=key.block_scope_id,
254
                student=User.objects.get(id=key.user_id),
255
            )
256
        elif key.scope == Scope.user_info:
257 258
            field_object, _ = XModuleStudentInfoField.objects.get_or_create(
                field_name=key.field_name,
259
                student=User.objects.get(id=key.user_id),
260 261 262 263 264 265 266
            )

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


Calen Pennington committed
267
class DjangoKeyValueStore(KeyValueStore):
268
    """
Calen Pennington committed
269 270
    This KeyValueStore will read and write data in the following scopes to django models
        Scope.user_state_summary
271 272 273
        Scope.user_state
        Scope.preferences
        Scope.user_info
Calen Pennington committed
274 275

    Access to any other scopes will raise an InvalidScopeError
276

277
    Data for Scope.user_state is stored as StudentModule objects via the django orm.
278 279 280 281 282 283

    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
284 285

    _allowed_scopes = (
Calen Pennington committed
286
        Scope.user_state_summary,
287 288 289
        Scope.user_state,
        Scope.preferences,
        Scope.user_info,
Calen Pennington committed
290
    )
291

292

Calen Pennington committed
293 294
    def __init__(self, field_data_cache):
        self._field_data_cache = field_data_cache
295

Calen Pennington committed
296
    def get(self, key):
Calen Pennington committed
297
        if key.scope not in self._allowed_scopes:
298
            raise InvalidScopeError(key)
Calen Pennington committed
299

Calen Pennington committed
300
        field_object = self._field_data_cache.find(key)
301
        if field_object is None:
302 303
            raise KeyError(key.field_name)

304
        if key.scope == Scope.user_state:
305 306 307 308
            return json.loads(field_object.state)[key.field_name]
        else:
            return json.loads(field_object.value)

309
    def set(self, key, value):
310 311 312 313
        """
        Set a single value in the KeyValueStore
        """
        self.set_many({key: value})
314

315 316 317 318 319
    def set_many(self, kv_dict):
        """
        Provide a bulk save mechanism.

        `kv_dict`: A dictionary of dirty fields that maps
320
          xblock.KvsFieldData._key : value
321 322 323 324 325 326

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

331
            # If the field is valid and isn't already in the dictionary, add it.
Calen Pennington committed
332
            field_object = self._field_data_cache.find_or_create(field)
333 334
            if field_object not in field_objects.keys():
                field_objects[field_object] = []
335
            # Update the list of associated fields
336 337
            field_objects[field_object].append(field)

338
            # Special case when scope is for the user state, because this scope saves fields in a single row
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
            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:
            # The remaining scopes save fields on different rows, so
            # we don't have to worry about conflicts
                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:
356
                log.exception('Error saving fields %r', field_objects[field_object])
357 358
                raise KeyValueMultiSaveError(saved_fields)

359
    def delete(self, key):
Calen Pennington committed
360
        if key.scope not in self._allowed_scopes:
361
            raise InvalidScopeError(key)
Calen Pennington committed
362

Calen Pennington committed
363
        field_object = self._field_data_cache.find(key)
364
        if field_object is None:
365 366
            raise KeyError(key.field_name)

367
        if key.scope == Scope.user_state:
368 369 370 371 372 373
            state = json.loads(field_object.state)
            del state[key.field_name]
            field_object.state = json.dumps(state)
            field_object.save()
        else:
            field_object.delete()
374

375 376
    def has(self, key):
        if key.scope not in self._allowed_scopes:
377
            raise InvalidScopeError(key)
378

Calen Pennington committed
379
        field_object = self._field_data_cache.find(key)
380 381 382
        if field_object is None:
            return False

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