user_state_client.py 14.5 KB
Newer Older
1 2 3 4 5
"""
An implementation of :class:`XBlockUserStateClient`, which stores XBlock Scope.user_state
data in a Django ORM model.
"""

6 7
import itertools
from operator import attrgetter
8

9 10 11 12
try:
    import simplejson as json
except ImportError:
    import json
13

14
from django.contrib.auth.models import User
15
from xblock.fields import Scope, ScopeBase
16
from edx_user_state_client.interface import XBlockUserStateClient
17
from courseware.models import StudentModule, StudentModuleHistory
18 19 20 21
from contracts import contract, new_contract
from opaque_keys.edx.keys import UsageKey

new_contract('UsageKey', UsageKey)
22 23 24


class DjangoXBlockUserStateClient(XBlockUserStateClient):
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
    """
    An interface that uses the Django ORM StudentModule as a backend.
    """

    class ServiceUnavailable(XBlockUserStateClient.ServiceUnavailable):
        """
        This error is raised if the service backing this client is currently unavailable.
        """
        pass

    class PermissionDenied(XBlockUserStateClient.PermissionDenied):
        """
        This error is raised if the caller is not allowed to access the requested data.
        """
        pass

    class DoesNotExist(XBlockUserStateClient.DoesNotExist):
        """
        This error is raised if the caller has requested data that does not exist.
        """
        pass

47 48 49 50 51 52 53
    def __init__(self, user=None):
        """
        Arguments:
            user (:class:`~User`): An already-loaded django user. If this user matches the username
                supplied to `set_many`, then that will reduce the number of queries made to store
                the user state.
        """
54 55
        self.user = user

56 57 58 59 60 61
    @contract(
        username="basestring",
        block_key=UsageKey,
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
62
    def get(self, username, block_key, scope=Scope.user_state, fields=None):
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
        """
        Retrieve the stored XBlock state for a single xblock usage.

        Arguments:
            username: The name of the user whose state should be retrieved
            block_key (UsageKey): The UsageKey identifying which xblock state to load.
            scope (Scope): The scope to load data from
            fields: A list of field values to retrieve. If None, retrieve all stored fields.

        Returns:
            dict: A dictionary mapping field names to values

        Raises:
            DoesNotExist if no entry is found.
        """
        try:
            _usage_key, state = next(self.get_many(username, [block_key], scope, fields=fields))
        except StopIteration:
            raise self.DoesNotExist()

        return state
84

85
    @contract(username="basestring", block_key=UsageKey, state="dict(basestring: *)", scope=ScopeBase)
86
    def set(self, username, block_key, state, scope=Scope.user_state):
87 88 89 90 91
        """
        Set fields for a particular XBlock.

        Arguments:
            username: The name of the user whose state should be retrieved
92
            block_key (UsageKey): The UsageKey identifying which xblock state to update.
93 94 95
            state (dict): A dictionary mapping field names to values
            scope (Scope): The scope to load data from
        """
96 97
        self.set_many(username, {block_key: state}, scope)

98 99 100 101 102 103
    @contract(
        username="basestring",
        block_key=UsageKey,
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
104 105 106 107 108 109 110 111 112 113 114 115
    def delete(self, username, block_key, scope=Scope.user_state, fields=None):
        """
        Delete the stored XBlock state for a single xblock usage.

        Arguments:
            username: The name of the user whose state should be deleted
            block_key (UsageKey): The UsageKey identifying which xblock state to delete.
            scope (Scope): The scope to delete data from
            fields: A list of fields to delete. If None, delete all stored fields.
        """
        return self.delete_many(username, [block_key], scope, fields=fields)

116 117 118 119 120 121
    @contract(
        username="basestring",
        block_key=UsageKey,
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
    def get_mod_date(self, username, block_key, scope=Scope.user_state, fields=None):
        """
        Get the last modification date for fields from the specified blocks.

        Arguments:
            username: The name of the user whose state should be deleted
            block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve.
            scope (Scope): The scope to retrieve from.
            fields: A list of fields to query. If None, delete all stored fields.
                Specific implementations are free to return the same modification date
                for all fields, if they don't store changes individually per field.
                Implementations may omit fields for which data has not been stored.

        Returns: list a dict of {field_name: modified_date} for each selected field.
        """
        results = self.get_mod_date_many(username, [block_key], scope, fields=fields)
        return {
            field: date for (_, field, date) in results
        }

    @contract(username="basestring", block_keys="seq(UsageKey)|set(UsageKey)")
143
    def _get_student_modules(self, username, block_keys):
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
        """
        Retrieve the :class:`~StudentModule`s for the supplied ``username`` and ``block_keys``.

        Arguments:
            username (str): The name of the user to load `StudentModule`s for.
            block_keys (list of :class:`~UsageKey`): The set of XBlocks to load data for.
        """
        course_key_func = attrgetter('course_key')
        by_course = itertools.groupby(
            sorted(block_keys, key=course_key_func),
            course_key_func,
        )

        for course_key, usage_keys in by_course:
            query = StudentModule.objects.chunked_filter(
                'module_state_key__in',
160
                usage_keys,
161 162 163 164 165 166 167 168
                student__username=username,
                course_id=course_key,
            )

            for student_module in query:
                usage_key = student_module.module_state_key.map_into_course(student_module.course_id)
                yield (student_module, usage_key)

169 170 171 172 173 174
    @contract(
        username="basestring",
        block_keys="seq(UsageKey)|set(UsageKey)",
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
175
    def get_many(self, username, block_keys, scope=Scope.user_state, fields=None):
176 177 178 179 180 181 182 183 184 185 186 187 188
        """
        Retrieve the stored XBlock state for a single xblock usage.

        Arguments:
            username: The name of the user whose state should be retrieved
            block_keys ([UsageKey]): A list of UsageKeys identifying which xblock states to load.
            scope (Scope): The scope to load data from
            fields: A list of field values to retrieve. If None, retrieve all stored fields.

        Yields:
            (UsageKey, field_state) tuples for each specified UsageKey in block_keys.
            field_state is a dict mapping field names to values.
        """
189
        if scope != Scope.user_state:
190
            raise ValueError("Only Scope.user_state is supported, not {}".format(scope))
191

192
        modules = self._get_student_modules(username, block_keys)
193 194 195 196 197 198 199
        for module, usage_key in modules:
            if module.state is None:
                state = {}
            else:
                state = json.loads(module.state)
            yield (usage_key, state)

200
    @contract(username="basestring", block_keys_to_state="dict(UsageKey: dict(basestring: *))", scope=ScopeBase)
201
    def set_many(self, username, block_keys_to_state, scope=Scope.user_state):
202 203 204 205 206 207 208 209 210 211 212
        """
        Set fields for a particular XBlock.

        Arguments:
            username: The name of the user whose state should be retrieved
            block_keys_to_state (dict): A dict mapping UsageKeys to state dicts.
                Each state dict maps field names to values. These state dicts
                are overlaid over the stored state. To delete fields, use
                :meth:`delete` or :meth:`delete_many`.
            scope (Scope): The scope to load data from
        """
213 214 215
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")

216 217 218 219
        # We do a find_or_create for every block (rather than re-using field objects
        # that were queried in get_many) so that if the score has
        # been changed by some other piece of the code, we don't overwrite
        # that score.
220 221 222 223 224
        if self.user.username == username:
            user = self.user
        else:
            user = User.objects.get(username=username)

225 226
        for usage_key, state in block_keys_to_state.items():
            student_module, created = StudentModule.objects.get_or_create(
227
                student=user,
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245
                course_id=usage_key.course_key,
                module_state_key=usage_key,
                defaults={
                    'state': json.dumps(state),
                    'module_type': usage_key.block_type,
                },
            )

            if not created:
                if student_module.state is None:
                    current_state = {}
                else:
                    current_state = json.loads(student_module.state)
                current_state.update(state)
                student_module.state = json.dumps(current_state)
                # We just read this object, so we know that we can do an update
                student_module.save(force_update=True)

246 247 248 249 250 251
    @contract(
        username="basestring",
        block_keys="seq(UsageKey)|set(UsageKey)",
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
252 253 254 255 256 257 258 259 260 261
    def delete_many(self, username, block_keys, scope=Scope.user_state, fields=None):
        """
        Delete the stored XBlock state for a many xblock usages.

        Arguments:
            username: The name of the user whose state should be deleted
            block_key (UsageKey): The UsageKey identifying which xblock state to delete.
            scope (Scope): The scope to delete data from
            fields: A list of fields to delete. If None, delete all stored fields.
        """
262 263
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")
264

265
        student_modules = self._get_student_modules(username, block_keys)
266 267
        for student_module, _ in student_modules:
            if fields is None:
268
                student_module.state = "{}"
269
            else:
270
                current_state = json.loads(student_module.state)
271 272 273 274 275 276 277 278
                for field in fields:
                    if field in current_state:
                        del current_state[field]

                student_module.state = json.dumps(current_state)
            # We just read this object, so we know that we can do an update
            student_module.save(force_update=True)

279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
    @contract(
        username="basestring",
        block_keys="seq(UsageKey)|set(UsageKey)",
        scope=ScopeBase,
        fields="seq(basestring)|set(basestring)|None"
    )
    def get_mod_date_many(self, username, block_keys, scope=Scope.user_state, fields=None):
        """
        Get the last modification date for fields from the specified blocks.

        Arguments:
            username: The name of the user whose state should be deleted
            block_key (UsageKey): The UsageKey identifying which xblock modification dates to retrieve.
            scope (Scope): The scope to retrieve from.
            fields: A list of fields to query. If None, delete all stored fields.
                Specific implementations are free to return the same modification date
                for all fields, if they don't store changes individually per field.
                Implementations may omit fields for which data has not been stored.

        Yields: tuples of (block, field_name, modified_date) for each selected field.
        """
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")

303 304 305
        student_modules = self._get_student_modules(username, block_keys)
        for student_module, usage_key in student_modules:
            if student_module.state is None:
306 307
                continue

308 309
            for field in json.loads(student_module.state):
                yield (usage_key, field, student_module.modified)
310

311
    @contract(username="basestring", block_key=UsageKey, scope=ScopeBase)
312
    def get_history(self, username, block_key, scope=Scope.user_state):
313 314 315 316 317 318 319 320 321 322
        """
        Retrieve history of state changes for a given block for a given
        student.  We don't guarantee that history for many blocks will be fast.

        Arguments:
            username: The name of the user whose history should be retrieved
            block_key (UsageKey): The UsageKey identifying which xblock state to update.
            scope (Scope): The scope to load data from
        """

323 324
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345
        student_modules = list(
            student_module
            for student_module, usage_id
            in self._get_student_modules(username, [block_key])
        )
        if len(student_modules) == 0:
            raise self.DoesNotExist()

        history_entries = StudentModuleHistory.objects.filter(
            student_module__in=student_modules
        ).order_by('-id')

        # If no history records exist, let's force a save to get history started.
        if not history_entries:
            for student_module in student_modules:
                student_module.save()
            history_entries = StudentModuleHistory.objects.filter(
                student_module__in=student_modules
            ).order_by('-id')

        return history_entries
346

347
    def iter_all_for_block(self, block_key, scope=Scope.user_state, batch_size=None):
348 349 350 351 352
        """
        You get no ordering guarantees. Fetching will happen in batch_size
        increments. If you're using this method, you should be running in an
        async task.
        """
353 354
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")
355 356
        raise NotImplementedError()

357
    def iter_all_for_course(self, course_key, block_type=None, scope=Scope.user_state, batch_size=None):
358 359 360 361 362
        """
        You get no ordering guarantees. Fetching will happen in batch_size
        increments. If you're using this method, you should be running in an
        async task.
        """
363 364
        if scope != Scope.user_state:
            raise ValueError("Only Scope.user_state is supported")
365
        raise NotImplementedError()