model_data.py 13.9 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

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

log = logging.getLogger(__name__)
23 24 25


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


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


Calen Pennington committed
40
class FieldDataCache(object):
41 42
    """
    A cache of django model objects needed to supply the data
43
    for a module and its decendants
44 45 46 47 48 49 50 51 52
    """
    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
53
        descriptors: A list of XModuleDescriptors.
54 55
        course_id: The id of the current course
        user: The user for which to cache data
56
        select_for_update: True if rows should be locked until end of transaction
57 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
        '''
        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):
85 86 87 88 89 90 91 92 93
            """
            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
            """
94 95 96 97 98 99 100 101
            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

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

            return descriptors

        descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)

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

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

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

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

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

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

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

        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

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

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


Calen Pennington committed
261
class DjangoKeyValueStore(KeyValueStore):
262
    """
Calen Pennington committed
263 264
    This KeyValueStore will read and write data in the following scopes to django models
        Scope.user_state_summary
265 266 267
        Scope.user_state
        Scope.preferences
        Scope.user_info
Calen Pennington committed
268 269

    Access to any other scopes will raise an InvalidScopeError
270

271
    Data for Scope.user_state is stored as StudentModule objects via the django orm.
272 273 274 275 276 277

    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
278 279

    _allowed_scopes = (
Calen Pennington committed
280
        Scope.user_state_summary,
281 282 283
        Scope.user_state,
        Scope.preferences,
        Scope.user_info,
Calen Pennington committed
284
    )
285

286

Calen Pennington committed
287 288
    def __init__(self, field_data_cache):
        self._field_data_cache = field_data_cache
289

Calen Pennington committed
290
    def get(self, key):
Calen Pennington committed
291
        if key.scope not in self._allowed_scopes:
292
            raise InvalidScopeError(key)
Calen Pennington committed
293

Calen Pennington committed
294
        field_object = self._field_data_cache.find(key)
295
        if field_object is None:
296 297
            raise KeyError(key.field_name)

298
        if key.scope == Scope.user_state:
299 300 301 302
            return json.loads(field_object.state)[key.field_name]
        else:
            return json.loads(field_object.value)

303
    def set(self, key, value):
304 305 306 307
        """
        Set a single value in the KeyValueStore
        """
        self.set_many({key: value})
308

309 310 311 312 313 314 315 316 317 318 319 320
    def set_many(self, kv_dict):
        """
        Provide a bulk save mechanism.

        `kv_dict`: A dictionary of dirty fields that maps
          xblock.DbModel._key : value

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

325
            # If the field is valid and isn't already in the dictionary, add it.
Calen Pennington committed
326
            field_object = self._field_data_cache.find_or_create(field)
327 328
            if field_object not in field_objects.keys():
                field_objects[field_object] = []
329
            # Update the list of associated fields
330 331
            field_objects[field_object].append(field)

332
            # Special case when scope is for the user state, because this scope saves fields in a single row
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352
            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:
                log.error('Error saving fields %r', field_objects[field_object])
                raise KeyValueMultiSaveError(saved_fields)

353
    def delete(self, key):
Calen Pennington committed
354
        if key.scope not in self._allowed_scopes:
355
            raise InvalidScopeError(key)
Calen Pennington committed
356

Calen Pennington committed
357
        field_object = self._field_data_cache.find(key)
358
        if field_object is None:
359 360
            raise KeyError(key.field_name)

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

369 370
    def has(self, key):
        if key.scope not in self._allowed_scopes:
371
            raise InvalidScopeError(key)
372

Calen Pennington committed
373
        field_object = self._field_data_cache.find(key)
374 375 376
        if field_object is None:
            return False

377
        if key.scope == Scope.user_state:
378 379 380
            return key.field_name in json.loads(field_object.state)
        else:
            return True